From ea579e96692e7f585b509333b699ff5fb7a17ab7 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 12:43:25 +0800 Subject: [PATCH 01/11] Clean up C++ files and update submodule for Ruby SDK - Remove C++ files: .clang-format, cmake/, CMakeLists.txt, Makefile, src/, include/, examples/, tests/, build.sh - Remove C++ CI workflow (pr-format-check.yml) - Update submodule from gopher-mcp to gopher-orch (commit 6b45ffbb) - Update .gitignore for Ruby development --- .gitignore | 143 +- .gitmodules | 7 +- src/CMakeLists.txt | 103 - third_party/gopher-mcp | 1 - .../gopher-orch/.clang-format | 0 .../.github}/workflows/pr-format-check.yml | 0 third_party/gopher-orch/.gitignore | 112 + third_party/gopher-orch/.gitmodules | 5 + .../gopher-orch/CMakeLists.txt | 76 +- third_party/gopher-orch/LICENSE | 201 + Makefile => third_party/gopher-orch/Makefile | 16 +- third_party/gopher-orch/README.md | 382 ++ build.sh => third_party/gopher-orch/build.sh | 0 .../cmake}/cmake_uninstall.cmake.in | 0 .../cmake}/gopher-orch-config.cmake.in | 0 third_party/gopher-orch/docker/Dockerfile | 120 + third_party/gopher-orch/docker/README.md | 78 + .../gopher-orch/docker/build-and-push.sh | 182 + third_party/gopher-orch/docs/Agent.md | 497 +++ third_party/gopher-orch/docs/AgentRunnable.md | 863 ++++ third_party/gopher-orch/docs/Composition.md | 258 ++ third_party/gopher-orch/docs/FFI.md | 414 ++ third_party/gopher-orch/docs/GatewayServer.md | 1080 +++++ .../gopher-orch/docs/JsonToAgentPipeline.md | 1556 +++++++ third_party/gopher-orch/docs/LLMProvider.md | 331 ++ .../docs/MCP-Gateway-Config-Reference.md | 275 ++ .../docs/MCP-Gateway-Deployment.md | 905 ++++ third_party/gopher-orch/docs/Resilience.md | 323 ++ third_party/gopher-orch/docs/Runnable.md | 222 + third_party/gopher-orch/docs/Server.md | 301 ++ third_party/gopher-orch/docs/StateGraph.md | 305 ++ third_party/gopher-orch/docs/ToolRegistry.md | 485 +++ .../gopher-orch/examples}/CMakeLists.txt | 3 + .../gopher-orch/examples/chatbot/README.md | 109 + .../gopher-orch/examples/chatbot/main.cc | 154 + .../examples}/hello_world/CMakeLists.txt | 0 .../examples}/hello_world/main.cpp | 0 .../examples}/mcp_client/CMakeLists.txt | 0 .../mcp_client/mcp_client_example.cc | 14 +- .../examples/multi_agent/README.md | 159 + .../gopher-orch/examples/multi_agent/main.cc | 257 ++ .../examples/resilient_api/README.md | 127 + .../examples/resilient_api/main.cc | 277 ++ .../gopher-orch/examples/sdk/CMakeLists.txt | 113 + .../examples/sdk/client_example.cpp | 217 + .../examples/sdk/client_example_api.cpp | 62 + .../examples/sdk/client_example_json.cpp | 95 + .../sdk/client_example_json_list_tool.cpp | 154 + .../client_example_json_list_tool_agent.cpp | 104 + .../examples/sdk/gateway_server_example.cpp | 78 + .../sdk/gateway_server_example_test.cpp | 96 + .../examples/sdk/test_error_response.cpp | 93 + .../examples/sdk/typescript/README.md | 109 + .../sdk/typescript/client_example_api_run.sh | 66 + .../sdk/typescript/client_example_json_run.sh | 66 + .../examples/sdk/typescript/package-lock.json | 68 + .../examples/sdk/typescript/package.json | 20 + .../sdk/typescript/src/client_example_api.ts | 33 + .../sdk/typescript/src/client_example_json.ts | 106 + .../examples/sdk/typescript/tsconfig.json | 17 + .../examples/simple_agent/README.md | 73 + .../gopher-orch/examples/simple_agent/main.cc | 165 + .../gopher-orch/examples/workflow/README.md | 151 + .../gopher-orch/examples/workflow/main.cc | 230 + .../include/gopher/orch/agent/agent.h | 205 + .../include/gopher/orch/agent/agent_module.h | 70 + .../gopher/orch/agent/agent_runnable.h | 216 + .../include/gopher/orch/agent/agent_types.h | 484 +++ .../include/gopher/orch/agent/api_engine.h | 58 + .../include/gopher/orch/agent/config_loader.h | 501 +++ .../gopher/orch/agent/rest_tool_adapter.h | 294 ++ .../gopher/orch/agent/tool_definition.h | 354 ++ .../include/gopher/orch/agent/tool_executor.h | 156 + .../include/gopher/orch/agent/tool_registry.h | 522 +++ .../include/gopher/orch/agent/tool_runnable.h | 128 + .../include/gopher/orch/agent/tools_fetcher.h | 100 + .../gopher/orch/callback/callback_handler.h | 292 ++ .../gopher/orch/callback/callback_manager.h | 483 +++ .../gopher/orch/composition/parallel.h | 183 + .../include/gopher/orch/composition/router.h | 147 + .../gopher/orch/composition/sequence.h | 207 + .../include/gopher/orch/core/config.h | 145 + .../include/gopher/orch/core/lambda.h | 145 + .../include/gopher/orch/core/runnable.h | 115 + .../include/gopher/orch/core/types.h | 121 + .../include/gopher/orch/ffi/orch_ffi.h | 1406 +++++++ .../include/gopher/orch/ffi/orch_ffi_bridge.h | 865 ++++ .../include/gopher/orch/ffi/orch_ffi_raii.h | 554 +++ .../include/gopher/orch/ffi/orch_ffi_types.h | 561 +++ .../include/gopher/orch/fsm/state_machine.h | 335 ++ .../gopher/orch/graph/compiled_graph.h | 190 + .../include/gopher/orch/graph/graph_node.h | 56 + .../include/gopher/orch/graph/graph_state.h | 277 ++ .../include/gopher/orch/graph/state_graph.h | 187 + .../include/gopher/orch/human/approval.h | 464 +++ .../gopher/orch/llm/anthropic_provider.h | 139 + .../gopher-orch/include/gopher/orch/llm/llm.h | 55 + .../include/gopher/orch/llm/llm_provider.h | 191 + .../include/gopher/orch/llm/llm_runnable.h | 120 + .../include/gopher/orch/llm/llm_types.h | 279 ++ .../include/gopher/orch/llm/openai_provider.h | 143 + .../gopher-orch/include/gopher/orch/orch.h | 263 ++ .../gopher/orch/resilience/circuit_breaker.h | 250 ++ .../include/gopher/orch/resilience/fallback.h | 155 + .../include/gopher/orch/resilience/retry.h | 208 + .../include/gopher/orch/resilience/timeout.h | 130 + .../gopher/orch/server/gateway_server.h | 207 + .../include/gopher/orch/server/mcp_server.h | 202 + .../include/gopher/orch/server/mock_server.h | 274 ++ .../include/gopher/orch/server/rest_server.h | 333 ++ .../include/gopher/orch/server/server.h | 142 + .../gopher/orch/server/server_composite.h | 412 ++ .../gopher-orch/include}/orch/core/hello.h | 0 .../gopher-orch/include}/orch/core/version.h | 0 .../gopher-orch/sdk/typescript/README.md | 120 + .../sdk/typescript/package-lock.json | 3690 +++++++++++++++++ .../gopher-orch/sdk/typescript/package.json | 38 + .../gopher-orch/sdk/typescript/src/agent.ts | 346 ++ .../gopher-orch/sdk/typescript/src/ffi.ts | 383 ++ .../sdk/typescript/src/ffi_bridge_old.js | 225 + .../gopher-orch/sdk/typescript/src/index.ts | 40 + .../sdk/typescript/src/native_ffi.js | 188 + .../sdk/typescript/src/real_ffi_executor.js | 197 + .../gopher-orch/sdk/typescript/src/types.ts | 66 + .../gopher-orch/sdk/typescript/tsconfig.json | 20 + third_party/gopher-orch/src/CMakeLists.txt | 255 ++ .../src/gopher/orch/agent/agent.cc | 751 ++++ .../src/gopher/orch/agent/agent_runnable.cc | 499 +++ .../src/gopher/orch/agent/api_engine.cc | 34 + .../src/gopher/orch/agent/config_loader.cc | 75 + .../src/gopher/orch/agent/tool_registry.cc | 323 ++ .../src/gopher/orch/agent/tool_runnable.cc | 213 + .../src/gopher/orch/agent/tools_fetcher.cpp | 197 + .../src/gopher/orch/ffi/orch_ffi_agent.cc | 252 ++ .../src/gopher/orch/llm/anthropic_provider.cc | 464 +++ .../src/gopher/orch/llm/llm_factory.cc | 60 + .../src/gopher/orch/llm/llm_runnable.cc | 248 ++ .../src/gopher/orch/llm/openai_provider.cc | 412 ++ .../gopher/orch/server/curl_http_client.cc | 300 ++ .../src/gopher/orch/server/gateway_server.cpp | 749 ++++ .../gopher/orch/server/mcp_gateway_main.cpp | 323 ++ .../src/gopher/orch/server/mcp_server.cc | 602 +++ .../src/gopher/orch/server/rest_server.cc | 408 ++ .../gopher-orch/src/orch/hello.cc | 0 .../gopher-orch/tests}/CMakeLists.txt | 62 + .../tests/gopher/orch/FFI/ffi_builder_test.cc | 111 + .../tests/gopher/orch/FFI/ffi_core_test.cc | 93 + .../tests/gopher/orch/FFI/ffi_error_test.cc | 95 + .../tests/gopher/orch/FFI/ffi_handle_test.cc | 159 + .../tests/gopher/orch/FFI/ffi_json_test.cc | 90 + .../tests/gopher/orch/FFI/ffi_lambda_test.cc | 112 + .../tests/gopher/orch/FFI/ffi_raii_test.cc | 236 ++ .../tests/gopher/orch/FFI/ffi_types_test.cc | 125 + .../tests/gopher/orch/agent_runnable_test.cc | 483 +++ .../tests/gopher/orch/agent_state_test.cc | 392 ++ .../tests/gopher/orch/agent_test.cc | 462 +++ .../gopher/orch/callback_manager_test.cc | 499 +++ .../tests/gopher/orch/circuit_breaker_test.cc | 96 + .../tests/gopher/orch/fallback_test.cc | 102 + .../tests/gopher/orch/human_approval_test.cc | 434 ++ .../tests/gopher/orch/integration_test.cc | 81 + .../tests/gopher/orch/lambda_test.cc | 73 + .../tests/gopher/orch/llm_provider_test.cc | 284 ++ .../tests/gopher/orch/llm_runnable_test.cc | 332 ++ .../tests/gopher/orch/mcp_server_test.cc | 106 + .../tests/gopher/orch/mock_http_client.h | 232 ++ .../tests/gopher/orch/mock_llm_provider.h | 238 ++ .../tests/gopher/orch/mock_server_test.cc | 105 + .../tests/gopher/orch/orch_test_fixture.h | 95 + .../tests/gopher/orch/parallel_test.cc | 84 + .../tests/gopher/orch/rest_server_test.cc | 517 +++ .../tests/gopher/orch/retry_test.cc | 85 + .../tests/gopher/orch/router_test.cc | 110 + .../tests/gopher/orch/sequence_test.cc | 87 + .../gopher/orch/server_composite_test.cc | 372 ++ .../tests/gopher/orch/state_graph_test.cc | 376 ++ .../tests/gopher/orch/state_machine_test.cc | 226 + .../tests/gopher/orch/timeout_test.cc | 64 + .../tests/gopher/orch/tool_registry_test.cc | 766 ++++ .../tests/gopher/orch/tool_runnable_test.cc | 389 ++ .../orch/tools_fetcher_integration_test.cpp | 501 +++ .../tests/gopher/orch/tools_fetcher_test.cpp | 367 ++ .../gopher-orch/tests}/orch/hello_test.cpp | 0 .../gopher-orch/third_party/gopher-mcp | 1 + 184 files changed, 46067 insertions(+), 234 deletions(-) delete mode 100644 src/CMakeLists.txt delete mode 160000 third_party/gopher-mcp rename .clang-format => third_party/gopher-orch/.clang-format (100%) rename {.github => third_party/gopher-orch/.github}/workflows/pr-format-check.yml (100%) create mode 100644 third_party/gopher-orch/.gitignore create mode 100644 third_party/gopher-orch/.gitmodules rename CMakeLists.txt => third_party/gopher-orch/CMakeLists.txt (76%) create mode 100644 third_party/gopher-orch/LICENSE rename Makefile => third_party/gopher-orch/Makefile (96%) create mode 100644 third_party/gopher-orch/README.md rename build.sh => third_party/gopher-orch/build.sh (100%) rename {cmake => third_party/gopher-orch/cmake}/cmake_uninstall.cmake.in (100%) rename {cmake => third_party/gopher-orch/cmake}/gopher-orch-config.cmake.in (100%) create mode 100644 third_party/gopher-orch/docker/Dockerfile create mode 100644 third_party/gopher-orch/docker/README.md create mode 100755 third_party/gopher-orch/docker/build-and-push.sh create mode 100644 third_party/gopher-orch/docs/Agent.md create mode 100644 third_party/gopher-orch/docs/AgentRunnable.md create mode 100644 third_party/gopher-orch/docs/Composition.md create mode 100644 third_party/gopher-orch/docs/FFI.md create mode 100644 third_party/gopher-orch/docs/GatewayServer.md create mode 100644 third_party/gopher-orch/docs/JsonToAgentPipeline.md create mode 100644 third_party/gopher-orch/docs/LLMProvider.md create mode 100644 third_party/gopher-orch/docs/MCP-Gateway-Config-Reference.md create mode 100644 third_party/gopher-orch/docs/MCP-Gateway-Deployment.md create mode 100644 third_party/gopher-orch/docs/Resilience.md create mode 100644 third_party/gopher-orch/docs/Runnable.md create mode 100644 third_party/gopher-orch/docs/Server.md create mode 100644 third_party/gopher-orch/docs/StateGraph.md create mode 100644 third_party/gopher-orch/docs/ToolRegistry.md rename {examples => third_party/gopher-orch/examples}/CMakeLists.txt (69%) create mode 100644 third_party/gopher-orch/examples/chatbot/README.md create mode 100644 third_party/gopher-orch/examples/chatbot/main.cc rename {examples => third_party/gopher-orch/examples}/hello_world/CMakeLists.txt (100%) rename {examples => third_party/gopher-orch/examples}/hello_world/main.cpp (100%) rename {examples => third_party/gopher-orch/examples}/mcp_client/CMakeLists.txt (100%) rename {examples => third_party/gopher-orch/examples}/mcp_client/mcp_client_example.cc (90%) create mode 100644 third_party/gopher-orch/examples/multi_agent/README.md create mode 100644 third_party/gopher-orch/examples/multi_agent/main.cc create mode 100644 third_party/gopher-orch/examples/resilient_api/README.md create mode 100644 third_party/gopher-orch/examples/resilient_api/main.cc create mode 100644 third_party/gopher-orch/examples/sdk/CMakeLists.txt create mode 100644 third_party/gopher-orch/examples/sdk/client_example.cpp create mode 100644 third_party/gopher-orch/examples/sdk/client_example_api.cpp create mode 100644 third_party/gopher-orch/examples/sdk/client_example_json.cpp create mode 100644 third_party/gopher-orch/examples/sdk/client_example_json_list_tool.cpp create mode 100644 third_party/gopher-orch/examples/sdk/client_example_json_list_tool_agent.cpp create mode 100644 third_party/gopher-orch/examples/sdk/gateway_server_example.cpp create mode 100644 third_party/gopher-orch/examples/sdk/gateway_server_example_test.cpp create mode 100644 third_party/gopher-orch/examples/sdk/test_error_response.cpp create mode 100644 third_party/gopher-orch/examples/sdk/typescript/README.md create mode 100755 third_party/gopher-orch/examples/sdk/typescript/client_example_api_run.sh create mode 100755 third_party/gopher-orch/examples/sdk/typescript/client_example_json_run.sh create mode 100644 third_party/gopher-orch/examples/sdk/typescript/package-lock.json create mode 100644 third_party/gopher-orch/examples/sdk/typescript/package.json create mode 100644 third_party/gopher-orch/examples/sdk/typescript/src/client_example_api.ts create mode 100644 third_party/gopher-orch/examples/sdk/typescript/src/client_example_json.ts create mode 100644 third_party/gopher-orch/examples/sdk/typescript/tsconfig.json create mode 100644 third_party/gopher-orch/examples/simple_agent/README.md create mode 100644 third_party/gopher-orch/examples/simple_agent/main.cc create mode 100644 third_party/gopher-orch/examples/workflow/README.md create mode 100644 third_party/gopher-orch/examples/workflow/main.cc create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/agent.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/agent_module.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/agent_runnable.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/agent_types.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/api_engine.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/config_loader.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/rest_tool_adapter.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/tool_definition.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/tool_executor.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/tool_registry.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/tool_runnable.h create mode 100644 third_party/gopher-orch/include/gopher/orch/agent/tools_fetcher.h create mode 100644 third_party/gopher-orch/include/gopher/orch/callback/callback_handler.h create mode 100644 third_party/gopher-orch/include/gopher/orch/callback/callback_manager.h create mode 100644 third_party/gopher-orch/include/gopher/orch/composition/parallel.h create mode 100644 third_party/gopher-orch/include/gopher/orch/composition/router.h create mode 100644 third_party/gopher-orch/include/gopher/orch/composition/sequence.h create mode 100644 third_party/gopher-orch/include/gopher/orch/core/config.h create mode 100644 third_party/gopher-orch/include/gopher/orch/core/lambda.h create mode 100644 third_party/gopher-orch/include/gopher/orch/core/runnable.h create mode 100644 third_party/gopher-orch/include/gopher/orch/core/types.h create mode 100644 third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi.h create mode 100644 third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_bridge.h create mode 100644 third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_raii.h create mode 100644 third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_types.h create mode 100644 third_party/gopher-orch/include/gopher/orch/fsm/state_machine.h create mode 100644 third_party/gopher-orch/include/gopher/orch/graph/compiled_graph.h create mode 100644 third_party/gopher-orch/include/gopher/orch/graph/graph_node.h create mode 100644 third_party/gopher-orch/include/gopher/orch/graph/graph_state.h create mode 100644 third_party/gopher-orch/include/gopher/orch/graph/state_graph.h create mode 100644 third_party/gopher-orch/include/gopher/orch/human/approval.h create mode 100644 third_party/gopher-orch/include/gopher/orch/llm/anthropic_provider.h create mode 100644 third_party/gopher-orch/include/gopher/orch/llm/llm.h create mode 100644 third_party/gopher-orch/include/gopher/orch/llm/llm_provider.h create mode 100644 third_party/gopher-orch/include/gopher/orch/llm/llm_runnable.h create mode 100644 third_party/gopher-orch/include/gopher/orch/llm/llm_types.h create mode 100644 third_party/gopher-orch/include/gopher/orch/llm/openai_provider.h create mode 100644 third_party/gopher-orch/include/gopher/orch/orch.h create mode 100644 third_party/gopher-orch/include/gopher/orch/resilience/circuit_breaker.h create mode 100644 third_party/gopher-orch/include/gopher/orch/resilience/fallback.h create mode 100644 third_party/gopher-orch/include/gopher/orch/resilience/retry.h create mode 100644 third_party/gopher-orch/include/gopher/orch/resilience/timeout.h create mode 100644 third_party/gopher-orch/include/gopher/orch/server/gateway_server.h create mode 100644 third_party/gopher-orch/include/gopher/orch/server/mcp_server.h create mode 100644 third_party/gopher-orch/include/gopher/orch/server/mock_server.h create mode 100644 third_party/gopher-orch/include/gopher/orch/server/rest_server.h create mode 100644 third_party/gopher-orch/include/gopher/orch/server/server.h create mode 100644 third_party/gopher-orch/include/gopher/orch/server/server_composite.h rename {include => third_party/gopher-orch/include}/orch/core/hello.h (100%) rename {include => third_party/gopher-orch/include}/orch/core/version.h (100%) create mode 100644 third_party/gopher-orch/sdk/typescript/README.md create mode 100644 third_party/gopher-orch/sdk/typescript/package-lock.json create mode 100644 third_party/gopher-orch/sdk/typescript/package.json create mode 100644 third_party/gopher-orch/sdk/typescript/src/agent.ts create mode 100644 third_party/gopher-orch/sdk/typescript/src/ffi.ts create mode 100644 third_party/gopher-orch/sdk/typescript/src/ffi_bridge_old.js create mode 100644 third_party/gopher-orch/sdk/typescript/src/index.ts create mode 100644 third_party/gopher-orch/sdk/typescript/src/native_ffi.js create mode 100644 third_party/gopher-orch/sdk/typescript/src/real_ffi_executor.js create mode 100644 third_party/gopher-orch/sdk/typescript/src/types.ts create mode 100644 third_party/gopher-orch/sdk/typescript/tsconfig.json create mode 100644 third_party/gopher-orch/src/CMakeLists.txt create mode 100644 third_party/gopher-orch/src/gopher/orch/agent/agent.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/agent/agent_runnable.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/agent/api_engine.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/agent/config_loader.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/agent/tool_registry.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/agent/tool_runnable.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/agent/tools_fetcher.cpp create mode 100644 third_party/gopher-orch/src/gopher/orch/ffi/orch_ffi_agent.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/llm/anthropic_provider.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/llm/llm_factory.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/llm/llm_runnable.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/llm/openai_provider.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/server/curl_http_client.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/server/gateway_server.cpp create mode 100644 third_party/gopher-orch/src/gopher/orch/server/mcp_gateway_main.cpp create mode 100644 third_party/gopher-orch/src/gopher/orch/server/mcp_server.cc create mode 100644 third_party/gopher-orch/src/gopher/orch/server/rest_server.cc rename src/orch/hello.cpp => third_party/gopher-orch/src/orch/hello.cc (100%) rename {tests => third_party/gopher-orch/tests}/CMakeLists.txt (51%) create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_builder_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_core_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_error_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_handle_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_json_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_lambda_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_raii_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/FFI/ffi_types_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/agent_runnable_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/agent_state_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/agent_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/callback_manager_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/circuit_breaker_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/fallback_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/human_approval_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/integration_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/lambda_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/llm_provider_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/llm_runnable_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/mcp_server_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/mock_http_client.h create mode 100644 third_party/gopher-orch/tests/gopher/orch/mock_llm_provider.h create mode 100644 third_party/gopher-orch/tests/gopher/orch/mock_server_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/orch_test_fixture.h create mode 100644 third_party/gopher-orch/tests/gopher/orch/parallel_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/rest_server_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/retry_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/router_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/sequence_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/server_composite_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/state_graph_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/state_machine_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/timeout_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/tool_registry_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/tool_runnable_test.cc create mode 100644 third_party/gopher-orch/tests/gopher/orch/tools_fetcher_integration_test.cpp create mode 100644 third_party/gopher-orch/tests/gopher/orch/tools_fetcher_test.cpp rename {tests => third_party/gopher-orch/tests}/orch/hello_test.cpp (100%) create mode 160000 third_party/gopher-orch/third_party/gopher-mcp diff --git a/.gitignore b/.gitignore index 457c7687..f20d116b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,109 +1,64 @@ -# Build directories -build/ -build-*/ -build_*/ -cmake-build-*/ -out/ -bin/ -lib/ -# Exception: Allow Ruby SDK lib directory -!sdk/ruby/lib/ - -# CMake generated files -CMakeCache.txt -CMakeFiles/ -cmake_install.cmake -CTestTestfile.cmake -Testing/ -_deps/ -# Note: We have a hand-written Makefile at root, so only ignore generated ones in subdirs -*/Makefile - -# Compiled object files -*.o -*.obj -*.lo -*.slo +# Ruby specific +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ -# Precompiled Headers -*.gch -*.pch +# Documentation cache and generated files +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ -# Compiled Dynamic libraries -*.so -*.dylib -*.dll +# Environment normalization +/.bundle/ +/lib/bundler/man/ -# Compiled Static libraries -*.lai -*.la -*.a -*.lib +# Bundler config +/.bundle +/Gemfile.lock -# Executables -*.exe -*.out -*.app -test_variant -test_variant_advanced -test_variant_extensive -test_optional -test_optional_advanced -test_optional_extensive -test_type_helpers -test_mcp_types -test_mcp_types_extended -test_mcp_type_helpers -test_compat -test_buffer -test_json -test_event_loop -test_io_socket_handle -test_address -test_socket -test_socket_interface -test_socket_option +# YARD artifacts +/.yard/ -# IDE specific files -.vscode/ +# IDE - RubyMine .idea/ -*.swp -*.swo -*~ -.DS_Store +*.iws +*.iml +*.ipr -# Debug files -*.dSYM/ -*.su -*.idb -*.pdb +# IDE - VS Code +.vscode/ -# Dependency directories -node_modules/ -vendor/ +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db -# Coverage files -*.gcov -*.gcda -*.gcno -coverage/ -*.info +# Native library build +native/ +cmake-build-*/ -# Documentation -docs/html/ -docs/latex/ -doxygen/ +# Logs +*.log # Temporary files *.tmp *.temp -*.log - -# Python cache (if using Python scripts) -__pycache__/ -*.py[cod] -*$py.class +*.swp +*.swo +*~ -# OS generated files -Thumbs.db -Desktop.ini \ No newline at end of file +# Node.js (for example MCP servers) +node_modules/ diff --git a/.gitmodules b/.gitmodules index f7c1a54e..d5cd4211 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,3 @@ -[submodule "third_party/gopher-mcp"] - path = third_party/gopher-mcp - url = https://github.com/GopherSecurity/gopher-mcp.git - branch = main +[submodule "third_party/gopher-orch"] + path = third_party/gopher-orch + url = https://github.com/GopherSecurity/gopher-orch.git diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt deleted file mode 100644 index 709369ef..00000000 --- a/src/CMakeLists.txt +++ /dev/null @@ -1,103 +0,0 @@ -# gopher-orch source files - -# Core library sources (orch-specific extensions) -set(ORCH_CORE_SOURCES - orch/hello.cpp -) - -# Combine all sources -set(GOPHER_ORCH_SOURCES - ${ORCH_CORE_SOURCES} -) - -# Build static library -if(BUILD_STATIC_LIBS) - add_library(gopher-orch-static STATIC ${GOPHER_ORCH_SOURCES}) - target_include_directories(gopher-orch-static PUBLIC - $ - $ - $ - ) - - # Link dependencies - if(NOT BUILD_WITHOUT_GOPHER_MCP) - target_link_libraries(gopher-orch-static PUBLIC - ${GOPHER_MCP_LIBRARIES} - Threads::Threads - ) - else() - target_link_libraries(gopher-orch-static PUBLIC - Threads::Threads - ) - endif() - - set_target_properties(gopher-orch-static PROPERTIES - OUTPUT_NAME gopher-orch - POSITION_INDEPENDENT_CODE ON - ) - - # Set the main library alias - add_library(gopher-orch ALIAS gopher-orch-static) - - # Installation - install(TARGETS gopher-orch-static - EXPORT gopher-orch-targets - LIBRARY DESTINATION lib - ARCHIVE DESTINATION lib - RUNTIME DESTINATION bin - COMPONENT libraries - ) -endif() - -# Build shared library -if(BUILD_SHARED_LIBS) - add_library(gopher-orch-shared SHARED ${GOPHER_ORCH_SOURCES}) - target_include_directories(gopher-orch-shared PUBLIC - $ - $ - $ - ) - - # Link dependencies - if(NOT BUILD_WITHOUT_GOPHER_MCP) - target_link_libraries(gopher-orch-shared PUBLIC - ${GOPHER_MCP_LIBRARIES} - Threads::Threads - ) - else() - target_link_libraries(gopher-orch-shared PUBLIC - Threads::Threads - ) - endif() - - set_target_properties(gopher-orch-shared PROPERTIES - OUTPUT_NAME gopher-orch - VERSION ${PROJECT_VERSION} - SOVERSION ${PROJECT_VERSION_MAJOR} - ) - - # If only building shared, set it as the main library - if(NOT BUILD_STATIC_LIBS) - add_library(gopher-orch ALIAS gopher-orch-shared) - endif() - - # Installation - install(TARGETS gopher-orch-shared - EXPORT gopher-orch-targets - LIBRARY DESTINATION lib - ARCHIVE DESTINATION lib - RUNTIME DESTINATION bin - COMPONENT libraries - ) -endif() - -# Export targets only when not using submodule -# (When using submodule, gopher-mcp targets aren't installable) -if(NOT USE_SUBMODULE_GOPHER_MCP) - install(EXPORT gopher-orch-targets - FILE gopher-orch-targets.cmake - NAMESPACE gopher-orch:: - DESTINATION lib/cmake/gopher-orch - COMPONENT development - ) -endif() diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp deleted file mode 160000 index 5f6d6fd4..00000000 --- a/third_party/gopher-mcp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5f6d6fd4c70149eacab072cf9ba9a333107c0d36 diff --git a/.clang-format b/third_party/gopher-orch/.clang-format similarity index 100% rename from .clang-format rename to third_party/gopher-orch/.clang-format diff --git a/.github/workflows/pr-format-check.yml b/third_party/gopher-orch/.github/workflows/pr-format-check.yml similarity index 100% rename from .github/workflows/pr-format-check.yml rename to third_party/gopher-orch/.github/workflows/pr-format-check.yml diff --git a/third_party/gopher-orch/.gitignore b/third_party/gopher-orch/.gitignore new file mode 100644 index 00000000..f8dc7919 --- /dev/null +++ b/third_party/gopher-orch/.gitignore @@ -0,0 +1,112 @@ +# Build directories +build/ +build-*/ +build_*/ +cmake-build-*/ +out/ +bin/ +lib/ +# Exception: Allow Ruby SDK lib directory +!sdk/ruby/lib/ + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +CTestTestfile.cmake +Testing/ +_deps/ +# Note: We have a hand-written Makefile at root, so only ignore generated ones in subdirs +*/Makefile + +# Compiled object files +*.o +*.obj +*.lo +*.slo + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app +test_variant +test_variant_advanced +test_variant_extensive +test_optional +test_optional_advanced +test_optional_extensive +test_type_helpers +test_mcp_types +test_mcp_types_extended +test_mcp_type_helpers +test_compat +test_buffer +test_json +test_event_loop +test_io_socket_handle +test_address +test_socket +test_socket_interface +test_socket_option + +# IDE specific files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb + +# Dependency directories +node_modules/ +vendor/ + +# Coverage files +*.gcov +*.gcda +*.gcno +coverage/ +*.info + +# Documentation +docs/html/ +docs/latex/ +doxygen/ + +# Temporary files +*.tmp +*.temp +*.log + +# Python cache (if using Python scripts) +__pycache__/ +*.py[cod] +*$py.class + +# OS generated files +Thumbs.db +Desktop.ini +# TypeScript compiled output +examples/sdk/typescript/dist/ +sdk/typescript/dist/ diff --git a/third_party/gopher-orch/.gitmodules b/third_party/gopher-orch/.gitmodules new file mode 100644 index 00000000..06dba06b --- /dev/null +++ b/third_party/gopher-orch/.gitmodules @@ -0,0 +1,5 @@ +[submodule "third_party/gopher-mcp"] + path = third_party/gopher-mcp + url = https://github.com/GopherSecurity/gopher-mcp.git + branch = dev_improve_client_and_server + update = none diff --git a/CMakeLists.txt b/third_party/gopher-orch/CMakeLists.txt similarity index 76% rename from CMakeLists.txt rename to third_party/gopher-orch/CMakeLists.txt index 80c90036..b9638ce8 100644 --- a/CMakeLists.txt +++ b/third_party/gopher-orch/CMakeLists.txt @@ -12,9 +12,9 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) message(STATUS "Using C++14") -# Default to Debug build +# Default to Release build for better performance if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) endif() # Build options @@ -25,6 +25,7 @@ option(BUILD_EXAMPLES "Build examples" ON) option(ORCH_STRICT_WARNINGS "Enable strict compiler warnings" OFF) option(USE_SUBMODULE_GOPHER_MCP "Use gopher-mcp as submodule (vs find_package)" ON) option(BUILD_WITHOUT_GOPHER_MCP "Build without gopher-mcp dependency (for testing)" OFF) +option(BUILD_API_PRODUCT "Build for production API environment" OFF) # Set output directories set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) @@ -40,6 +41,15 @@ elseif(CMAKE_BUILD_TYPE STREQUAL "Release") add_compile_definitions(NDEBUG) endif() +# Configure API environment +if(BUILD_API_PRODUCT) + add_compile_definitions(BUILD_API_PRODUCT=1) + message(STATUS "Configuring for production API environment") +else() + add_compile_definitions(BUILD_API_PRODUCT=0) + message(STATUS "Configuring for test API environment") +endif() + # Platform-specific settings if(APPLE) set(CMAKE_MACOSX_RPATH ON) @@ -81,29 +91,29 @@ if(BUILD_WITHOUT_GOPHER_MCP) elseif(USE_SUBMODULE_GOPHER_MCP) # Use gopher-mcp as submodule if(NOT EXISTS "${CMAKE_SOURCE_DIR}/third_party/gopher-mcp/.git") - message(STATUS "gopher-mcp submodule not found. Initializing...") - execute_process( - COMMAND git submodule add https://github.com/GopherSecurity/gopher-mcp.git third_party/gopher-mcp - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - RESULT_VARIABLE GIT_SUBMOD_RESULT - ) - if(NOT GIT_SUBMOD_RESULT EQUAL "0") - # Submodule might already exist, try update - execute_process( - COMMAND git submodule update --init --recursive third_party/gopher-mcp - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - RESULT_VARIABLE GIT_SUBMOD_UPDATE_RESULT - ) - if(NOT GIT_SUBMOD_UPDATE_RESULT EQUAL "0") - message(FATAL_ERROR "Failed to initialize gopher-mcp submodule") - endif() - endif() +# message(STATUS "gopher-mcp submodule not found. Initializing...") +# execute_process( +# COMMAND git submodule add https://github.com/GopherSecurity/gopher-mcp.git third_party/gopher-mcp +# WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} +# RESULT_VARIABLE GIT_SUBMOD_RESULT +# ) +# if(NOT GIT_SUBMOD_RESULT EQUAL "0") +# # Submodule might already exist, try update +# execute_process( +# COMMAND git submodule update --init --recursive third_party/gopher-mcp +# WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} +# RESULT_VARIABLE GIT_SUBMOD_UPDATE_RESULT +# ) +# if(NOT GIT_SUBMOD_UPDATE_RESULT EQUAL "0") +# message(FATAL_ERROR "Failed to initialize gopher-mcp submodule") +# endif() +# endif() else() - message(STATUS "Updating gopher-mcp submodule...") - execute_process( - COMMAND git submodule update --init --recursive third_party/gopher-mcp - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ) +# message(STATUS "Updating gopher-mcp submodule...") +# execute_process( +# COMMAND git submodule update --init --recursive third_party/gopher-mcp +# WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} +# ) endif() # Set include directories for gopher-mcp BEFORE adding subdirectory @@ -122,9 +132,17 @@ elseif(USE_SUBMODULE_GOPHER_MCP) # Disable fmt installation if gopher-mcp uses it set(FMT_INSTALL OFF CACHE BOOL "Disable fmt installation" FORCE) + + # Force gopher-mcp to build in Release mode for better performance + # This helps avoid crashes and improves stability + set(CMAKE_BUILD_TYPE_SAVED ${CMAKE_BUILD_TYPE}) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) # Add gopher-mcp subdirectory add_subdirectory(third_party/gopher-mcp EXCLUDE_FROM_ALL) + + # Restore our build type + set(CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE_SAVED} CACHE STRING "" FORCE) # Restore our settings set(BUILD_TESTS ${BUILD_TESTS_SAVED} CACHE BOOL "" FORCE) @@ -145,6 +163,15 @@ else() message(STATUS "Using system gopher-mcp: ${gopher-mcp_DIR}") endif() +# Find libcurl +find_package(CURL REQUIRED) +if(CURL_FOUND) + message(STATUS "Found CURL: ${CURL_LIBRARIES}") + message(STATUS "CURL include dirs: ${CURL_INCLUDE_DIRS}") +else() + message(FATAL_ERROR "libcurl not found. Please install libcurl development package.") +endif() + # Include directories message(STATUS "GOPHER_MCP_INCLUDE_DIR: ${GOPHER_MCP_INCLUDE_DIR}") include_directories( @@ -246,6 +273,7 @@ message(STATUS "Build shared libs: ${BUILD_SHARED_LIBS}") message(STATUS "Build static libs: ${BUILD_STATIC_LIBS}") message(STATUS "Build tests: ${BUILD_TESTS}") message(STATUS "Build examples: ${BUILD_EXAMPLES}") +message(STATUS "API Product build: ${BUILD_API_PRODUCT}") message(STATUS "Strict warnings: ${ORCH_STRICT_WARNINGS}") message(STATUS "Use submodule gopher-mcp: ${USE_SUBMODULE_GOPHER_MCP}") message(STATUS "Install prefix: ${CMAKE_INSTALL_PREFIX}") diff --git a/third_party/gopher-orch/LICENSE b/third_party/gopher-orch/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/third_party/gopher-orch/LICENSE @@ -0,0 +1,201 @@ + 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. diff --git a/Makefile b/third_party/gopher-orch/Makefile similarity index 96% rename from Makefile rename to third_party/gopher-orch/Makefile index 5639a8ff..bbf77bbe 100644 --- a/Makefile +++ b/third_party/gopher-orch/Makefile @@ -27,9 +27,22 @@ NC := \033[0m # No Color all: build test @echo "$(GREEN)Build and test completed successfully$(NC)" +# Initialize submodules if any are uninitialized (generic for all submodules) +.PHONY: init-submodules +init-submodules: + @if git submodule status | grep -q '^-'; then \ + echo "$(BLUE)Initializing git submodules...$(NC)"; \ + git submodule update --init --recursive; \ + if [ $$? -ne 0 ]; then \ + echo "$(RED)Failed to initialize submodules$(NC)"; \ + exit 1; \ + fi; \ + echo "$(GREEN)Submodules initialized$(NC)"; \ + fi + # Configure with CMake .PHONY: configure -configure: +configure: init-submodules @echo "$(BLUE)Configuring with CMake...$(NC)" @echo " Build type: $(BUILD_TYPE)" @echo " Static library: $(BUILD_STATIC)" @@ -343,6 +356,7 @@ help: @echo " make run-hello - Run hello world example" @echo "" @echo "$(GREEN)Dependency management:$(NC)" + @echo " make init-submodules - Initialize submodules (auto on build)" @echo " make update-submodules - Update git submodules" @echo " make use-system-gopher-mcp - Use system gopher-mcp" @echo " make use-submodule-gopher-mcp - Use submodule gopher-mcp" diff --git a/third_party/gopher-orch/README.md b/third_party/gopher-orch/README.md new file mode 100644 index 00000000..91c0b0ca --- /dev/null +++ b/third_party/gopher-orch/README.md @@ -0,0 +1,382 @@ +# Gopher Orch - Cross-Language MCP Orchestration Framework + +[![MCP](https://img.shields.io/badge/MCP-Native-green.svg)](https://modelcontextprotocol.io/) +[![Languages](https://img.shields.io/badge/C++%20%7C%20Python%20%7C%20Rust%20%7C%20Go%20%7C%20Node.js-blue.svg)]() +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey.svg)]() + +**LangChain + Vercel AI SDK for Model Context Protocol** + +Build composable AI agents and workflows in **C++, Python, Rust, Go, Node.js, and more** - with MCP built-in. + +## What is Gopher Orch? + +Gopher Orch is a **cross-language MCP orchestration framework** that provides composable building blocks for AI agents and workflows. Built on top of [gopher-mcp](https://github.com/anthropics/gopher-mcp), it enables developers to build ReAct agents, stateful workflows, and multi-step reasoning systems with enterprise-grade reliability - in any language. + +### Key Benefits + +- **MCP-Native**: First-class Model Context Protocol support - tools, resources, prompts built-in +- **Cross-Language**: Write agents in C++, Python, Rust, Go, Node.js, and more with unified API +- **LangChain-Style Composability**: Chain operations with `|` operator, build complex workflows from simple components +- **Vercel AI SDK Patterns**: Streaming, structured outputs, and modern async patterns +- **Production-Ready**: Circuit breaker, retry, timeout, and fallback patterns built-in +- **Testable-by-Design**: MockServer support for unit testing without network dependencies + +## Why Choose Gopher Orch? + +| Feature | Gopher Orch | LangChain | LlamaIndex | +|---------|-------------|-----------|------------| +| Languages | C++, Python, Rust, Go, Node.js, and more | Python | Python | +| MCP Support | Native (built-in) | Plugin | Plugin | +| Performance | Native speed, zero-copy | Interpreted | Interpreted | +| Type Safety | Compile-time checked | Runtime | Runtime | +| Composability | Explicit `Runnable` | Magic methods | Index abstractions | +| Streaming | Built-in | Callback-based | Callback-based | +| Memory Control | RAII, deterministic | GC-managed | GC-managed | + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ AI Agents │ Workflows │ State Graphs │ Chatbots │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ FFI Layer (Cross-Language) │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Python │ Rust │ Go │ Node.js │ Java │ C# │ Ruby │ Swift │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Orchestration Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Runnable │ │ StateGraph │ │ Resilience │ │ Agent │ │ +│ │ Composition │ │ (Pregel) │ │ Patterns │ │ (ReAct) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Server Abstraction Layer │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Protocol-Agnostic Server Interface │ Tool Registry │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Protocol Implementations │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ MCP Server │ │ REST Server │ │ Mock Server │ │ +│ └────────────────┘ └────────────────┘ └────────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Foundation (gopher-mcp) │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Dispatcher │ JsonValue │ Result │ Event Loop │ Transports │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Core Components + +### Runnable Interface - Universal Building Block + +The `Runnable` interface is the foundation of all composable operations: + +```cpp +#include "gopher/orch/orch.h" + +using namespace gopher::orch; + +// Create a simple lambda runnable +auto greet = makeLambda( + [](const std::string& name, Dispatcher& d, ResultCallback cb) { + cb(Result("Hello, " + name + "!")); + }); + +// Invoke asynchronously +greet->invoke("World", config, dispatcher, [](Result result) { + std::cout << mcp::get(result) << std::endl; +}); +``` + +### Composition Patterns + +Build complex workflows from simple components: + +```cpp +// Sequence: A | B | C (pipe pattern) +auto pipeline = makeSequence(step1, step2, step3); + +// Parallel: Run operations concurrently +auto parallel = makeParallel({taskA, taskB, taskC}); + +// Router: Conditional branching +auto router = makeRouter() + .addRoute("search", searchHandler) + .addRoute("calculate", calculateHandler) + .withDefault(defaultHandler) + .build(); +``` + +### ReAct Agent - Reasoning + Acting + +Build AI agents that reason about tasks and use tools: + +```cpp +#include "gopher/orch/agent/agent_runnable.h" + +// Create LLM provider +auto provider = makeOpenAIProvider(api_key, "gpt-4"); + +// Create tool registry +auto registry = makeToolRegistry(); +registry->addSyncTool("search", "Search the web", schema, + [](const JsonValue& args) -> Result { + // Tool implementation + return Result(searchResults); + }); + +// Create ReAct agent +auto agent = makeAgentRunnable(provider, registry, + AgentConfig("gpt-4") + .withSystemPrompt("You are a helpful assistant.") + .withMaxIterations(10)); + +// Run agent +JsonValue input = "What's the weather in Tokyo?"; +agent->invoke(input, config, dispatcher, [](Result result) { + auto output = mcp::get(result); + std::cout << output["response"].getString() << std::endl; +}); +``` + +### StateGraph - LangGraph-Style Workflows + +Build stateful workflows with conditional transitions: + +```cpp +#include "gopher/orch/graph/state_graph.h" + +// Define state with reducer +struct AgentState { + std::vector messages; // APPEND reducer + int step_count = 0; // LAST_WRITE_WINS + + static AgentState reduce(const AgentState& a, const AgentState& b); +}; + +// Build graph +auto graph = StateGraphBuilder() + .addNode("agent", agentNode) + .addNode("tools", toolsNode) + .addEdge(START, "agent") + .addConditionalEdge("agent", shouldContinue, { + {"continue", "tools"}, + {"end", END} + }) + .addEdge("tools", "agent") + .compile(); + +// Execute +graph->invoke(initialState, config, dispatcher, callback); +``` + +### Resilience Patterns + +Add production-grade reliability to any runnable: + +```cpp +// Retry with exponential backoff +auto reliable = makeRetry(unreliableOp, RetryConfig() + .withMaxAttempts(3) + .withBackoff(std::chrono::milliseconds(100))); + +// Timeout protection +auto bounded = makeTimeout(slowOp, std::chrono::seconds(30)); + +// Fallback on failure +auto safe = makeFallback(primaryOp, fallbackOp); + +// Circuit breaker for failure isolation +auto protected = makeCircuitBreaker(externalService, CircuitBreakerConfig() + .withFailureThreshold(5) + .withResetTimeout(std::chrono::seconds(60))); +``` + +### LLM Providers + +Built-in support for major LLM providers: + +```cpp +// OpenAI / GPT-4 +auto openai = makeOpenAIProvider(api_key, "gpt-4"); + +// Anthropic / Claude +auto anthropic = makeAnthropicProvider(api_key, "claude-3-opus-20240229"); + +// Use with LLMRunnable for composable LLM operations +auto llm = makeLLMRunnable(provider, LLMConfig() + .withModel("gpt-4") + .withTemperature(0.7)); +``` + +### Protocol-Agnostic Server + +Register tools once, expose via any protocol: + +```cpp +// Create server with tool registry +auto server = makeServer(registry, ServerConfig() + .withName("my-agent-server")); + +// Expose via MCP protocol +auto mcpServer = makeMCPServer(server, mcpConfig); +mcpServer->listen("tcp://0.0.0.0:8080"); + +// Or expose via REST API +auto restServer = makeRESTServer(server, restConfig); +restServer->listen("http://0.0.0.0:3000"); + +// Or use MockServer for testing +auto mockServer = makeMockServer(server); +mockServer->setToolResponse("search", mockResponse); +``` + +## Installation + +### Prerequisites + +- C++14 or later compiler (GCC 8+, Clang 10+, MSVC 2019+) +- CMake 3.10+ +- [gopher-mcp](https://github.com/anthropics/gopher-mcp) (auto-fetched as submodule) + +### Build from Source + +```bash +# Clone with submodules +git clone --recursive https://github.com/anthropics/gopher-orch.git +cd gopher-orch + +# Build +make + +# Run tests +make test + +# Install (auto-prompts for sudo if needed) +make install +``` + +### CMake Integration + +```cmake +# Option 1: FetchContent +include(FetchContent) +FetchContent_Declare( + gopher-orch + GIT_REPOSITORY https://github.com/anthropics/gopher-orch.git + GIT_TAG main +) +FetchContent_MakeAvailable(gopher-orch) + +target_link_libraries(your_target gopher-orch) + +# Option 2: Submodule +add_subdirectory(third_party/gopher-orch) +target_link_libraries(your_target gopher-orch) +``` + +## Use Cases + +### 1. AI Chatbots and Assistants +Build conversational AI agents with tool-calling capabilities, memory, and multi-turn reasoning. + +### 2. Autonomous Agents +Create agents that can break down complex tasks, use tools, and iterate until completion. + +### 3. Workflow Automation +Orchestrate multi-step business processes with conditional branching and error handling. + +### 4. RAG Pipelines +Build retrieval-augmented generation systems with composable retrieval and synthesis steps. + +### 5. Multi-Agent Systems +Coordinate multiple specialized agents working together on complex problems. + +### 6. API Orchestration +Compose multiple API calls with resilience patterns and parallel execution. + +## Cross-Language Support (FFI) + +Gopher Orch provides a stable C API for integration with other languages: + +```python +# Python example +from gopher_orch import Agent, ToolRegistry + +registry = ToolRegistry() +registry.add_tool("search", search_function) + +agent = Agent(provider, registry, config) +result = agent.invoke("What's the weather?") +``` + +Supported languages: +- **Python**: ctypes/cffi with async support +- **Rust**: Safe FFI wrappers +- **Go**: CGO integration +- **Node.js**: N-API bindings +- **Java**: JNI bindings +- **C#/.NET**: P/Invoke + +## Documentation + +- [Runnable Interface](docs/Runnable.md) - Core composable interface +- [Composition Patterns](docs/Composition.md) - Sequence, Parallel, Router +- [Agent Framework](docs/Agent.md) - ReAct agents and tool execution +- [StateGraph Guide](docs/StateGraph.md) - LangGraph-style stateful workflows +- [Resilience Patterns](docs/Resilience.md) - Retry, Timeout, Fallback, Circuit Breaker +- [Server Abstraction](docs/Server.md) - Protocol-agnostic server interface +- [FFI Guide](docs/FFI.md) - Cross-language integration + +## Examples + +See the [examples/](examples/) directory for complete working examples: + +- `examples/simple_agent/` - Basic ReAct agent with tools +- `examples/chatbot/` - Multi-turn conversational agent +- `examples/workflow/` - StateGraph-based workflow +- `examples/resilient_api/` - API client with resilience patterns +- `examples/multi_agent/` - Multi-agent coordination + +## Comparison with Other Frameworks + +### vs LangChain (Python) +- **Performance**: Native C++ vs interpreted Python +- **Type Safety**: Compile-time vs runtime errors +- **Memory**: Deterministic RAII vs garbage collection +- **Design**: Explicit interfaces vs magic methods + +### vs LlamaIndex (Python) +- **Focus**: General orchestration vs RAG-specific +- **Flexibility**: Protocol-agnostic vs LLM-focused +- **Composability**: Universal Runnable vs Index abstractions + +### vs Semantic Kernel (C#/.NET) +- **Language**: C++ with FFI vs .NET ecosystem +- **Portability**: Cross-platform native vs .NET runtime +- **Protocol**: MCP-native vs custom plugins + +## Contributing + +Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) before submitting pull requests. + +## License + +Apache License 2.0 - see [LICENSE](LICENSE) for details. + +## Related Projects + +- [gopher-mcp](https://github.com/anthropics/gopher-mcp) - C++ MCP SDK (foundation layer) +- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification +- [LangChain](https://github.com/langchain-ai/langchain) - Python AI orchestration +- [LlamaIndex](https://github.com/run-llama/llama_index) - Python RAG framework + +## Keywords & Search Terms + +`MCP SDK`, `MCP Framework`, `Model Context Protocol SDK`, `MCP Orchestration`, `MCP Agent`, `Cross-Language AI Agent`, `LangChain for MCP`, `Vercel AI SDK MCP`, `MCP Tools`, `MCP Python`, `MCP Rust`, `MCP Go`, `MCP Node.js`, `AI Agent Framework`, `ReAct Agent MCP`, `LangGraph MCP`, `Agentic AI MCP`, `MCP Server`, `MCP Client`, `Tool Calling MCP`, `AI Workflow MCP` diff --git a/build.sh b/third_party/gopher-orch/build.sh similarity index 100% rename from build.sh rename to third_party/gopher-orch/build.sh diff --git a/cmake/cmake_uninstall.cmake.in b/third_party/gopher-orch/cmake/cmake_uninstall.cmake.in similarity index 100% rename from cmake/cmake_uninstall.cmake.in rename to third_party/gopher-orch/cmake/cmake_uninstall.cmake.in diff --git a/cmake/gopher-orch-config.cmake.in b/third_party/gopher-orch/cmake/gopher-orch-config.cmake.in similarity index 100% rename from cmake/gopher-orch-config.cmake.in rename to third_party/gopher-orch/cmake/gopher-orch-config.cmake.in diff --git a/third_party/gopher-orch/docker/Dockerfile b/third_party/gopher-orch/docker/Dockerfile new file mode 100644 index 00000000..5b19a056 --- /dev/null +++ b/third_party/gopher-orch/docker/Dockerfile @@ -0,0 +1,120 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# MCP Gateway Server - Multi-Stage Docker Build +# ═══════════════════════════════════════════════════════════════════════════════ +# +# This Dockerfile builds a production-ready MCP Gateway Server. +# +# Build: +# docker build -t mcp-gateway -f docker/Dockerfile . +# +# Run: +# docker run -p 3003:3003 \ +# -e MCP_GATEWAY_CONFIG='{"version":"2026-01-11","metadata":{"gatewayId":"123"},"config":{},"servers":[{"serverId":"1","name":"server1","url":"http://host.docker.internal:3001/mcp"}]}' \ +# mcp-gateway +# +# Environment Variables: +# MCP_GATEWAY_CONFIG - JSON config string +# MCP_GATEWAY_CONFIG_PATH - Path to config file (default: /etc/mcp/gateway-config.json) +# MCP_GATEWAY_CONFIG_URL - API URL to fetch config +# MCP_GATEWAY_ACCESS_KEY - Access key for API auth +# MCP_GATEWAY_PORT - Server port (default: 3003) +# MCP_GATEWAY_HOST - Server host (default: 0.0.0.0) +# MCP_GATEWAY_NAME - Server name (default: mcp-gateway) +# +# ═══════════════════════════════════════════════════════════════════════════════ + +# ─────────────────────────────────────────────────────────────────────────────── +# Stage 1: Builder +# ─────────────────────────────────────────────────────────────────────────────── +FROM ubuntu:22.04 AS builder + +# Prevent interactive prompts during package installation +ENV DEBIAN_FRONTEND=noninteractive + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + cmake \ + git \ + pkg-config \ + libcurl4-openssl-dev \ + libssl-dev \ + libevent-dev \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /build + +# Copy source code +COPY . . + +# Initialize and update git submodules (for gopher-mcp) +RUN git submodule update --init --recursive || true + +# Configure CMake with Release build +RUN cmake -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTS=OFF \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_STATIC_LIBS=ON + +# Build the mcp_gateway binary +RUN cmake --build build --target mcp_gateway -j$(nproc) + +# Verify binary was built +RUN ls -la build/bin/mcp_gateway + +# ─────────────────────────────────────────────────────────────────────────────── +# Stage 2: Runtime +# ─────────────────────────────────────────────────────────────────────────────── +FROM ubuntu:22.04 AS runtime + +# Prevent interactive prompts +ENV DEBIAN_FRONTEND=noninteractive + +# Install runtime dependencies only +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcurl4 \ + libssl3 \ + libevent-2.1-7 \ + libevent-pthreads-2.1-7 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Create non-root user for security +RUN groupadd -r -g 1001 mcp && \ + useradd -r -u 1001 -g mcp mcp + +# Create config directory +RUN mkdir -p /etc/mcp && chown mcp:mcp /etc/mcp + +# Copy binary from builder +COPY --from=builder /build/build/bin/mcp_gateway /usr/local/bin/mcp_gateway + +# Make binary executable +RUN chmod +x /usr/local/bin/mcp_gateway + +# Switch to non-root user +USER mcp + +# Set working directory +WORKDIR /app + +# Default environment variables +ENV MCP_GATEWAY_PORT=3003 +ENV MCP_GATEWAY_HOST=0.0.0.0 +ENV MCP_GATEWAY_NAME=mcp-gateway +ENV MCP_GATEWAY_CONFIG_PATH=/etc/mcp/gateway-config.json + +# Expose default port +EXPOSE 3003 + +# Health check +# The gateway exposes /health endpoint +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${MCP_GATEWAY_PORT}/health || exit 1 + +# Run the gateway +ENTRYPOINT ["/usr/local/bin/mcp_gateway"] diff --git a/third_party/gopher-orch/docker/README.md b/third_party/gopher-orch/docker/README.md new file mode 100644 index 00000000..9d2fc36c --- /dev/null +++ b/third_party/gopher-orch/docker/README.md @@ -0,0 +1,78 @@ +# MCP Gateway Docker + +This directory contains the Docker build infrastructure for the MCP Gateway Server. + +## Files + +| File | Description | +|------|-------------| +| `Dockerfile` | Multi-stage build for production binary | +| `build-and-push.sh` | Script to build and push to AWS ECR | + +## Quick Start + +### Build and Push to ECR + +```bash +./docker/build-and-push.sh +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AWS_REGION` | `us-east-1` | AWS region for ECR | +| `AWS_ACCOUNT_ID` | `XX308818XX` | AWS account ID | +| `REPOSITORY_NAME` | `mcp-gateway` | ECR repository name | + +### Local Build + +```bash +# Build for local testing +docker build -t mcp-gateway -f docker/Dockerfile . +``` + +### Local Run + +```bash +# With environment variable config (manifest format) +docker run -p 3003:3003 \ + -e MCP_GATEWAY_CONFIG='{"version":"2026-01-11","metadata":{"gatewayId":"123"},"config":{},"servers":[{"serverId":"1","name":"server1","url":"http://host.docker.internal:3001/mcp"}]}' \ + mcp-gateway + +# With config file +docker run -p 3003:3003 \ + -v /path/to/config:/etc/mcp:ro \ + mcp-gateway +``` + +## Container Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MCP_GATEWAY_CONFIG` | - | JSON configuration string (highest priority) | +| `MCP_GATEWAY_CONFIG_PATH` | `/etc/mcp/gateway-config.json` | Path to config file | +| `MCP_GATEWAY_CONFIG_URL` | - | API URL to fetch configuration | +| `MCP_GATEWAY_ACCESS_KEY` | - | Access key for API authentication | +| `MCP_GATEWAY_PORT` | `3003` | Server listen port | +| `MCP_GATEWAY_HOST` | `0.0.0.0` | Server listen host | +| `MCP_GATEWAY_NAME` | `mcp-gateway` | Server name | + +## Health Check + +The container includes a built-in health check: + +```bash +curl http://localhost:3003/health +``` + +## Image Details + +- **Base Image**: Ubuntu 22.04 +- **Architectures**: `linux/amd64`, `linux/arm64` +- **User**: Non-root (`mcp:mcp`, UID/GID 1001) +- **Port**: 3003 (configurable) + +## Full Documentation + +See [MCP Gateway Deployment Guide](../docs/MCP-Gateway-Deployment.md) for complete deployment instructions. diff --git a/third_party/gopher-orch/docker/build-and-push.sh b/third_party/gopher-orch/docker/build-and-push.sh new file mode 100755 index 00000000..34edd6cd --- /dev/null +++ b/third_party/gopher-orch/docker/build-and-push.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ═══════════════════════════════════════════════════════════════════════════════ +# MCP Gateway - Build and Push to ECR +# ═══════════════════════════════════════════════════════════════════════════════ +# +# This script builds a multi-architecture Docker image for the MCP Gateway +# and pushes it to AWS ECR. +# +# Usage: +# ./docker/build-and-push.sh +# +# Environment Variables (optional): +# AWS_REGION - AWS region (default: us-east-1) +# AWS_ACCOUNT_ID - AWS account ID (required, or set below) +# REPOSITORY_NAME - ECR repository name (default: mcp-gateway) +# +# ═══════════════════════════════════════════════════════════════════════════════ + +# ────────────────────────────────────────────────────────────────────────────── +# Configuration +# ────────────────────────────────────────────────────────────────────────────── +AWS_REGION="${AWS_REGION:-us-east-1}" +AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID:-745308818994}" # Set your AWS Account ID +REPOSITORY_NAME="${REPOSITORY_NAME:-mcp-gateway}" + +# Tags: timestamped version + stable + arch-specific +VERSION="$(date +%Y.%m.%d-%H%M)" +IMAGE_TAGS=("latest" "amd64" "arm64" "$VERSION") + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +ECR_REPO="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${REPOSITORY_NAME}" + +# ────────────────────────────────────────────────────────────────────────────── +# Script directory handling +# ────────────────────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} MCP Gateway - Docker Build & Push${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo +echo -e "${YELLOW}Repository:${NC} ${ECR_REPO}" +echo -e "${YELLOW}Version:${NC} ${VERSION}" +echo -e "${YELLOW}Project:${NC} ${PROJECT_ROOT}" +echo + +# ────────────────────────────────────────────────────────────────────────────── +# Preflight checks +# ────────────────────────────────────────────────────────────────────────────── +echo -e "${YELLOW}Running preflight checks...${NC}" + +# Check AWS CLI +if ! command -v aws >/dev/null 2>&1; then + echo -e "${RED}Error: AWS CLI not found${NC}" + echo "Install with: brew install awscli" + exit 1 +fi + +# Check Docker +if ! docker info >/dev/null 2>&1; then + echo -e "${RED}Error: Docker is not running${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ AWS CLI found${NC}" +echo -e "${GREEN}✓ Docker is running${NC}" +echo + +# ────────────────────────────────────────────────────────────────────────────── +# ECR Setup +# ────────────────────────────────────────────────────────────────────────────── +echo -e "${YELLOW}Setting up ECR...${NC}" + +# Ensure repository exists (idempotent) +if ! aws ecr describe-repositories \ + --repository-names "${REPOSITORY_NAME}" \ + --region "${AWS_REGION}" >/dev/null 2>&1; then + echo -e "${YELLOW}Creating ECR repository: ${REPOSITORY_NAME}${NC}" + aws ecr create-repository \ + --repository-name "${REPOSITORY_NAME}" \ + --region "${AWS_REGION}" >/dev/null +fi + +# Login to ECR +echo -e "${YELLOW}Logging in to ECR...${NC}" +aws ecr get-login-password --region "${AWS_REGION}" \ + | docker login --username AWS --password-stdin \ + "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + +echo -e "${GREEN}✓ ECR setup complete${NC}" +echo + +# ────────────────────────────────────────────────────────────────────────────── +# Docker Buildx Setup +# ────────────────────────────────────────────────────────────────────────────── +echo -e "${YELLOW}Setting up Docker Buildx...${NC}" + +BUILDER_NAME="mcp-gateway-builder" + +# Create/select builder if needed +if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then + echo -e "${YELLOW}Creating buildx builder: ${BUILDER_NAME}${NC}" + docker buildx create --name "${BUILDER_NAME}" --use >/dev/null +else + docker buildx use "${BUILDER_NAME}" >/dev/null +fi + +echo -e "${GREEN}✓ Buildx builder ready${NC}" +echo + +# ────────────────────────────────────────────────────────────────────────────── +# Build and Push +# ────────────────────────────────────────────────────────────────────────────── +echo -e "${YELLOW}Building multi-arch image (linux/amd64, linux/arm64)...${NC}" +echo -e "${YELLOW}This may take several minutes...${NC}" +echo + +# Construct -t flags for all tags +TAG_FLAGS=() +for t in "${IMAGE_TAGS[@]}"; do + TAG_FLAGS+=(-t "${ECR_REPO}:${t}") +done + +# Change to project root for Docker build context +cd "${PROJECT_ROOT}" + +# Build and push +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + "${TAG_FLAGS[@]}" \ + -f docker/Dockerfile \ + --push \ + . + +echo +echo -e "${GREEN}✓ Build and push complete${NC}" +echo + +# ────────────────────────────────────────────────────────────────────────────── +# Verify Manifest +# ────────────────────────────────────────────────────────────────────────────── +echo -e "${YELLOW}Verifying pushed manifest...${NC}" + +if docker buildx imagetools inspect "${ECR_REPO}:latest" >/dev/null 2>&1; then + docker buildx imagetools inspect "${ECR_REPO}:latest" +else + echo -e "${YELLOW}Note: docker buildx imagetools not available; skipping manifest inspection${NC}" +fi + +# ────────────────────────────────────────────────────────────────────────────── +# Summary +# ────────────────────────────────────────────────────────────────────────────── +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN}✅ Push complete!${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" +echo +echo -e "${GREEN}Images pushed:${NC}" +for t in "${IMAGE_TAGS[@]}"; do + echo -e " - ${ECR_REPO}:${t}" +done +echo +echo -e "${YELLOW}Kubernetes deployment:${NC}" +echo " kubectl set image deploy/ mcp-gateway=${ECR_REPO}:${VERSION}" +echo +echo -e "${YELLOW}Or use latest tag and restart pods:${NC}" +echo " kubectl delete pod -l app=mcp-gateway" +echo +echo -e "${YELLOW}Test locally:${NC}" +echo " docker run -p 3003:3003 \\" +echo " -e MCP_GATEWAY_CONFIG='{\"version\":\"2026-01-11\",\"metadata\":{\"gatewayId\":\"123\"},\"config\":{},\"servers\":[{\"serverId\":\"1\",\"name\":\"test\",\"url\":\"http://host.docker.internal:3001/mcp\"}]}' \\" +echo " ${ECR_REPO}:latest" +echo diff --git a/third_party/gopher-orch/docs/Agent.md b/third_party/gopher-orch/docs/Agent.md new file mode 100644 index 00000000..6e85e1f1 --- /dev/null +++ b/third_party/gopher-orch/docs/Agent.md @@ -0,0 +1,497 @@ +# Agent Design Document + +## Overview + +The Agent module implements the ReAct (Reasoning + Acting) pattern for building AI agents that can use tools to accomplish tasks. The agent iteratively calls an LLM, executes requested tools, and feeds results back until the task is complete. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ReActAgent │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ AgentConfig │ │ +│ │ • system_prompt • max_iterations • timeout │ │ +│ │ • llm_config • parallel_tool_calls │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ LLMProvider │ │ ToolExecutor │ │ AgentState │ │ +│ │ │ │ │ │ │ │ +│ │ • chat() │ │ • executeTool() │ │ • messages │ │ +│ │ • toolCalls │ │ • registry() │ │ • steps │ │ +│ └─────────────────┘ └─────────────────┘ │ • status │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## ReAct Loop Flow + +``` + ┌─────────────┐ + │ Start │ + └──────┬──────┘ + │ + ▼ + ┌───────────────────────┐ + │ Add user query to │ + │ message history │ + └───────────┬───────────┘ + │ + ┌────────────────┼────────────────┐ + │ ▼ │ + │ ┌───────────────────────┐ │ + │ │ Check iteration & │ │ + │ │ timeout limits │ │ + │ └───────────┬───────────┘ │ + │ │ │ + │ ┌──────┴──────┐ │ + │ │ Exceeded? │ │ + │ └──────┬──────┘ │ + │ Yes/ │ \No │ + │ / │ \ │ + │ ▼ │ ▼ │ + │ ┌─────────┐ │ ┌─────────────────┐ + │ │ FAIL │ │ │ Call LLM │ + │ └─────────┘ │ │ with tools │ + │ │ └────────┬────────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ Record step │ + │ │ └────────┬────────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────────────┐ + │ │ │ Has tool calls? │ + │ │ └────────┬────────┘ + │ │ Yes/ │ \No + │ │ / │ \ + │ │ ▼ │ ▼ + │ │ ┌──────────┐│ ┌──────────┐ + │ │ │ Execute ││ │ COMPLETE │ + │ │ │ tools ││ └──────────┘ + │ │ └────┬─────┘│ + │ │ │ │ + │ │ ▼ │ + │ │ ┌──────────┐│ + │ │ │Add tool ││ + │ │ │results to││ + │ │ │messages ││ + │ │ └────┬─────┘│ + │ │ │ │ + └─────────────────┼──────┘ │ + │ │ + └─────────────┘ + (loop) +``` + +## Core Components + +### 1. AgentConfig + +```cpp +struct AgentConfig { + LLMConfig llm_config; // Model settings + std::string system_prompt; // Agent behavior definition + int max_iterations = 10; // Prevent infinite loops + optional max_total_tokens; // Token budget + std::chrono::milliseconds timeout{300000}; // 5 min default + bool parallel_tool_calls = true; + + // Builder pattern + AgentConfig& withModel(const std::string& model); + AgentConfig& withSystemPrompt(const std::string& prompt); + AgentConfig& withMaxIterations(int iterations); + AgentConfig& withTemperature(double t); +}; +``` + +### 2. AgentState + +```cpp +enum class AgentStatus { + IDLE, // Not started + RUNNING, // Currently executing + COMPLETED, // Finished successfully + FAILED, // Error occurred + CANCELLED, // Cancelled by user + MAX_ITERATIONS_REACHED // Hit iteration limit +}; + +struct AgentState { + AgentStatus status; + std::vector messages; // Conversation history + std::vector steps; // Execution steps + int current_iteration; + Usage total_usage; // Token counts + optional error; +}; +``` + +### 3. AgentStep + +```cpp +struct ToolExecution { + std::string tool_name; + std::string call_id; + JsonValue input; + JsonValue output; + bool success; + std::string error_message; +}; + +struct AgentStep { + int step_number; + Message llm_message; + optional llm_usage; + std::vector tool_executions; + std::chrono::milliseconds llm_duration; +}; +``` + +### 4. Callbacks + +```cpp +// Called when agent completes +using AgentCallback = std::function)>; + +// Called after each step (for progress monitoring) +using StepCallback = std::function; + +// Called before tool execution (can approve/reject) +using ToolApprovalCallback = std::function; +``` + +## Detailed Execution Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ReActAgent::run() │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 1. Initialize State │ +│ • status = RUNNING │ +│ • Add context messages (if any) │ +│ • Add user query as USER message │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 2. executeLoop() │ +│ • Check cancellation flag │ +│ • Check iteration limit (current_iteration >= max_iterations) │ +│ • Check timeout (elapsed > config.timeout) │ +│ • Increment current_iteration │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 3. callLLM() │ +│ • Build messages from state │ +│ • Get tool specs from registry │ +│ • Call provider->chat(messages, tools, config, dispatcher, callback) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 4. On LLM Response │ +│ • Create AgentStep with LLM message and usage │ +│ • Record step (triggers step callback) │ +│ • Call handleLLMResponse() │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + Has Tool Calls? No Tool Calls + │ │ + ▼ ▼ +┌─────────────────────────────────┐ ┌─────────────────────────────────┐ +│ 5a. executeToolCalls() │ │ 5b. completeRun(COMPLETED) │ +│ • Check approval callback │ │ • Set status │ +│ • Call executor.executeTool │ │ • Build AgentResult │ +│ for each tool │ │ • Invoke completion callback│ +│ • Collect results │ └─────────────────────────────────┘ +└─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 6. handleToolResults() │ +│ • Update last step with tool executions │ +│ • Add TOOL messages for each result │ +│ • Post to dispatcher: executeLoop() (continue loop) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Example Usage + +### Basic Agent + +```cpp +#include "gopher/orch/agent/agent.h" +#include "gopher/orch/llm/openai_provider.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; + +// Create provider +auto provider = createOpenAIProvider("sk-your-api-key"); + +// Create tool registry +auto registry = makeToolRegistry(); + +// Add a simple tool +JsonValue searchSchema = JsonValue::object(); +searchSchema["type"] = "object"; +JsonValue props = JsonValue::object(); +JsonValue queryProp = JsonValue::object(); +queryProp["type"] = "string"; +props["query"] = queryProp; +searchSchema["properties"] = props; + +registry->addSyncTool("search", "Search the web", searchSchema, + [](const JsonValue& args) -> Result { + std::string query = args["query"].getString(); + // Perform search... + JsonValue result = JsonValue::object(); + result["results"] = "Search results for: " + query; + return Result(result); + }); + +// Configure agent +AgentConfig config("gpt-4"); +config.withSystemPrompt("You are a helpful assistant with web search capability.") + .withMaxIterations(5) + .withTemperature(0.7); + +// Create agent +auto agent = ReActAgent::create(provider, registry, config); + +// Run agent +agent->run("What is the weather like in Tokyo today?", dispatcher, + [](Result result) { + if (mcp::holds_alternative(result)) { + auto& agentResult = mcp::get(result); + std::cout << "Response: " << agentResult.response << std::endl; + std::cout << "Steps: " << agentResult.iterationCount() << std::endl; + std::cout << "Tokens: " << agentResult.total_usage.total_tokens << std::endl; + } else { + auto& error = mcp::get(result); + std::cerr << "Agent failed: " << error.message << std::endl; + } + }); +``` + +### Agent with Progress Monitoring + +```cpp +auto agent = ReActAgent::create(provider, registry, config); + +// Monitor each step +agent->setStepCallback([](const AgentStep& step) { + std::cout << "Step " << step.step_number << ":" << std::endl; + std::cout << " LLM response: " << step.llm_message.content << std::endl; + + if (!step.tool_executions.empty()) { + std::cout << " Tool executions:" << std::endl; + for (const auto& exec : step.tool_executions) { + std::cout << " - " << exec.tool_name + << (exec.success ? " (success)" : " (failed)") + << std::endl; + } + } +}); + +agent->run("Research the latest AI developments", dispatcher, callback); +``` + +### Agent with Tool Approval + +```cpp +auto agent = ReActAgent::create(provider, registry, config); + +// Require approval for dangerous tools +agent->setToolApprovalCallback([](const ToolCall& call) -> bool { + if (call.name == "delete_file" || call.name == "execute_command") { + std::cout << "Tool '" << call.name << "' requires approval." << std::endl; + std::cout << "Arguments: " << call.arguments.toString() << std::endl; + std::cout << "Approve? (y/n): "; + + std::string input; + std::getline(std::cin, input); + return input == "y" || input == "yes"; + } + return true; // Auto-approve other tools +}); + +agent->run("Clean up temp files", dispatcher, callback); +``` + +### Agent with Context + +```cpp +// Provide conversation history +std::vector context = { + Message::user("My name is Alice and I work at Acme Corp."), + Message::assistant("Hello Alice! Nice to meet you. How can I help you today?") +}; + +agent->run("What company do I work at?", context, dispatcher, + [](Result result) { + // Agent can access previous context + // Response: "You work at Acme Corp." + }); +``` + +### Multiple Tools Agent + +```cpp +auto registry = makeToolRegistry(); + +// Calculator tool +registry->addSyncTool("calculate", "Perform math calculations", calcSchema, + [](const JsonValue& args) -> Result { + std::string expr = args["expression"].getString(); + // Evaluate expression... + return Result(JsonValue(42.0)); + }); + +// Weather tool +registry->addSyncTool("get_weather", "Get current weather", weatherSchema, + [](const JsonValue& args) -> Result { + std::string city = args["city"].getString(); + JsonValue result = JsonValue::object(); + result["temperature"] = 72; + result["condition"] = "sunny"; + return Result(result); + }); + +// Time tool +registry->addSyncTool("get_time", "Get current time", timeSchema, + [](const JsonValue& args) -> Result { + JsonValue result = JsonValue::object(); + result["time"] = "2:30 PM"; + result["timezone"] = "PST"; + return Result(result); + }); + +// Agent can now use all three tools +agent->run( + "What's the weather in Seattle, what time is it there, and what is 15 * 7?", + dispatcher, callback); +``` + +### Cancellation + +```cpp +auto agent = ReActAgent::create(provider, registry, config); + +// Start long-running task +agent->run("Analyze this large dataset...", dispatcher, callback); + +// Cancel from another thread or timer +std::this_thread::sleep_for(std::chrono::seconds(30)); +if (agent->isRunning()) { + agent->cancel(); + // Callback will receive CANCELLED status +} +``` + +## Message Flow Example + +``` +User: "What's 25 * 4 and what's the weather in Paris?" + +┌───────────────────────────────────────────────────────────────────────────┐ +│ Iteration 1 │ +├───────────────────────────────────────────────────────────────────────────┤ +│ Messages to LLM: │ +│ [SYSTEM] You are a helpful assistant with tools. │ +│ [USER] What's 25 * 4 and what's the weather in Paris? │ +│ │ +│ LLM Response: │ +│ [ASSISTANT] I'll help you with both. Let me calculate and check weather. │ +│ Tool calls: │ +│ 1. calculate({expression: "25 * 4"}) │ +│ 2. get_weather({city: "Paris"}) │ +└───────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Tool Execution │ +├───────────────────────────────────────────────────────────────────────────┤ +│ calculate({expression: "25 * 4"}) → {result: 100} │ +│ get_weather({city: "Paris"}) → {temp: 18, condition: "cloudy"} │ +└───────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Iteration 2 │ +├───────────────────────────────────────────────────────────────────────────┤ +│ Messages to LLM: │ +│ [SYSTEM] You are a helpful assistant with tools. │ +│ [USER] What's 25 * 4 and what's the weather in Paris? │ +│ [ASSISTANT] I'll help you with both... │ +│ [TOOL] call_1: {result: 100} │ +│ [TOOL] call_2: {temp: 18, condition: "cloudy"} │ +│ │ +│ LLM Response: │ +│ [ASSISTANT] 25 × 4 = 100, and Paris is currently 18°C and cloudy. │ +│ (No tool calls - conversation complete) │ +└───────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + Agent COMPLETED + Response: "25 × 4 = 100, and Paris + is currently 18°C and cloudy." +``` + +## Error Handling + +```cpp +namespace AgentError { + enum : int { + OK = 0, + NO_PROVIDER = -200, // No LLM provider configured + NO_TOOLS = -201, // No tools available + MAX_ITERATIONS = -202, // Hit iteration limit + TIMEOUT = -203, // Timeout exceeded + TOOL_EXECUTION_FAILED = -204, + LLM_ERROR = -205, // LLM call failed + CANCELLED = -206, // User cancelled + UNKNOWN = -299 + }; +} + +// Handle different outcomes +agent->run(query, dispatcher, [](Result result) { + if (mcp::holds_alternative(result)) { + auto& r = mcp::get(result); + switch (r.status) { + case AgentStatus::COMPLETED: + // Success + break; + case AgentStatus::MAX_ITERATIONS_REACHED: + // Task too complex, consider breaking it down + break; + case AgentStatus::CANCELLED: + // User cancelled + break; + } + } else { + auto& error = mcp::get(result); + // Handle error based on code + } +}); +``` + +## Best Practices + +1. **Set appropriate limits**: Configure `max_iterations` and `timeout` based on task complexity +2. **Use clear system prompts**: Guide the agent's behavior and tool usage +3. **Handle tool errors gracefully**: Tools should return meaningful error messages +4. **Monitor with step callbacks**: Track progress for long-running tasks +5. **Implement approval for sensitive tools**: Use `ToolApprovalCallback` for destructive operations +6. **Provide relevant context**: Include conversation history when continuity matters diff --git a/third_party/gopher-orch/docs/AgentRunnable.md b/third_party/gopher-orch/docs/AgentRunnable.md new file mode 100644 index 00000000..f82e006d --- /dev/null +++ b/third_party/gopher-orch/docs/AgentRunnable.md @@ -0,0 +1,863 @@ +# Agent-Runnable Integration Design + +## Overview + +This document describes how `Agent`, `Runnable`, and `LLM` components work together in gopher-orch, enabling seamless composition of AI agents with other workflow components. + +The design is inspired by LangChain, LangGraph, and n8n patterns, adapted for C++ with async-first, dispatcher-based execution. + +## Goals + +1. **Composability**: Agents can be used anywhere a `Runnable` is expected +2. **Consistency**: Same patterns for LLM, Tools, and Agents +3. **Flexibility**: Support both direct Agent usage and Runnable composition +4. **Type Safety**: Leverage C++ templates while maintaining JSON interoperability + +## Architecture + +### Three-Level Runnable Hierarchy + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RUNNABLE LAYER │ +│ │ +│ Level 3: Graph Runnables (Complex Workflows) │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ CompiledStateGraph │ │ +│ │ (Nodes + Edges + State with Reducers) │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ Level 2: Composite Runnables (Composition Patterns) │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ +│ │ Sequence │ │ Parallel │ │ Router │ │ AgentRunnable │ │ +│ │ (A→B→C) │ │ (A|B|C) │ │ (if/else) │ │ (LLM↔Tools) │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────────────┘ │ +│ │ │ +│ Level 1: Primitive Runnables (Leaf Nodes) │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ +│ │ Lambda │ │LLMRunnable │ │ToolRunnable│ │ Other Leaves │ │ +│ │ (function) │ │ (LLM API) │ │(tool exec) │ │ │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────────────┘ │ +│ │ +│ Foundation: Runnable │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ invoke(input, config, dispatcher, callback) │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Component Relationships + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ LLMProvider │ │ToolRegistry │ │ ToolExecutor │ │ +│ │ (API calls) │ │ (storage) │ │ (execution) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ▼ └────────┬───────────────┘ │ +│ ┌──────────────┐ │ │ +│ │ LLMRunnable │ ▼ │ +│ │ (wrapper) │ ┌──────────────┐ │ +│ └──────┬───────┘ │ ToolRunnable │ │ +│ │ │ (wrapper) │ │ +│ │ └──────┬───────┘ │ +│ │ │ │ +│ └─────────────┬───────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ AgentRunnable │ │ +│ │ │ │ +│ │ ┌───────────────┐ │ │ +│ │ │ Agent Graph │ │ │ +│ │ │ (LLM↔Tools) │ │ │ +│ │ └───────────────┘ │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ Runnable│ │ +│ │ (composable) │ │ +│ └─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Core Components + +### 1. LLMRunnable + +Wraps `LLMProvider` as a `Runnable`. + +**Purpose**: Makes LLM calls composable with other Runnables. + +**Header**: `include/gopher/orch/llm/llm_runnable.h` + +```cpp +class LLMRunnable : public Runnable { + public: + explicit LLMRunnable(LLMProviderPtr provider, + const LLMConfig& config = LLMConfig()); + + std::string name() const override; + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override; + + private: + LLMProviderPtr provider_; + LLMConfig default_config_; +}; +``` + +**Input Schema**: +```json +{ + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello!"} + ], + "tools": [ + {"name": "search", "description": "...", "parameters": {...}} + ], + "config": { + "temperature": 0.7, + "max_tokens": 1000 + } +} +``` + +**Output Schema**: +```json +{ + "message": { + "role": "assistant", + "content": "Hi there!", + "tool_calls": [ + {"id": "call_1", "name": "search", "arguments": {"query": "..."}} + ] + }, + "finish_reason": "tool_calls", + "usage": { + "prompt_tokens": 50, + "completion_tokens": 20, + "total_tokens": 70 + } +} +``` + +### 2. ToolRunnable + +Wraps `ToolExecutor` as a `Runnable`. + +**Purpose**: Makes tool execution composable, supports parallel tool calls. + +**Header**: `include/gopher/orch/agent/tool_runnable.h` + +```cpp +class ToolRunnable : public Runnable { + public: + explicit ToolRunnable(ToolExecutorPtr executor); + + std::string name() const override; + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override; + + private: + ToolExecutorPtr executor_; +}; +``` + +**Input Schema** (single tool call): +```json +{ + "id": "call_123", + "name": "search", + "arguments": {"query": "weather in Tokyo"} +} +``` + +**Input Schema** (multiple tool calls - parallel execution): +```json +{ + "tool_calls": [ + {"id": "call_1", "name": "search", "arguments": {"query": "weather"}}, + {"id": "call_2", "name": "calculator", "arguments": {"expr": "2+2"}} + ] +} +``` + +**Output Schema**: +```json +{ + "results": [ + {"id": "call_1", "result": {"temperature": 25}, "success": true}, + {"id": "call_2", "result": 4, "success": true} + ] +} +``` + +### 3. AgentState + +State container that flows through the agent graph, with reducer support. + +**Header**: `include/gopher/orch/agent/agent_state.h` + +```cpp +struct AgentState { + std::vector messages; // Conversation history + int remaining_steps = 10; // Iteration counter + optional error; // Error state + + // Reducer: merge state updates (messages are APPENDED) + static AgentState reduce(const AgentState& current, + const AgentState& update); + + // Serialize to/from JSON for graph nodes + JsonValue toJson() const; + static AgentState fromJson(const JsonValue& json); +}; +``` + +**Reducer Semantics**: +```cpp +// Messages use APPEND reducer (like LangGraph's add_messages) +AgentState AgentState::reduce(const AgentState& current, + const AgentState& update) { + AgentState result; + + // Append new messages to existing + result.messages = current.messages; + for (const auto& msg : update.messages) { + result.messages.push_back(msg); + } + + // Other fields use last-write-wins + result.remaining_steps = update.remaining_steps; + result.error = update.error; + + return result; +} +``` + +### 4. AgentRunnable + +The main integration point - wraps Agent functionality as a composable Runnable. + +**Header**: `include/gopher/orch/agent/agent_runnable.h` + +```cpp +class AgentRunnable : public Runnable { + public: + using Ptr = std::shared_ptr; + + // Factory methods + static Ptr create(LLMProviderPtr provider, + ToolExecutorPtr tools, + const AgentConfig& config = AgentConfig()); + + static Ptr create(LLMProviderPtr provider, + ToolRegistryPtr registry, + const AgentConfig& config = AgentConfig()); + + std::string name() const override; + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override; + + // Accessors + void setStepCallback(StepCallback callback); + void setToolApprovalCallback(ToolApprovalCallback callback); + + private: + // Internal graph nodes + std::shared_ptr llm_node_; + std::shared_ptr tool_node_; + AgentConfig config_; + + // Graph execution + void runLoop(AgentState& state, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback); + + std::string shouldContinue(const AgentState& state); +}; +``` + +**Input Schema**: +```json +{ + "query": "What is the weather in Tokyo?", + "context": [ + {"role": "user", "content": "Previous message"} + ], + "config": { + "max_iterations": 5 + } +} +``` + +Alternative input formats (auto-detected): +```json +// String input +"What is the weather?" + +// LangGraph-style messages input +{ + "messages": [ + {"role": "user", "content": "What is the weather?"} + ] +} +``` + +**Output Schema**: +```json +{ + "response": "The weather in Tokyo is 25°C and sunny.", + "status": "completed", + "iterations": 2, + "messages": [...], + "usage": { + "prompt_tokens": 150, + "completion_tokens": 50, + "total_tokens": 200 + }, + "duration_ms": 3500 +} +``` + +## Agent Internal Graph Structure + +AgentRunnable internally operates as a graph, following the LangGraph pattern: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AGENT INTERNAL GRAPH │ +└─────────────────────────────────────────────────────────────────────────────┘ + + INPUT + │ + ▼ + ┌─────────────────────┐ + │ Parse Input │ + │ (extract query, │ + │ context, config) │ + └──────────┬──────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Initialize State │ + │ AgentState { │ + │ messages: [...], │ + │ remaining: 10 │ + │ } │ + └──────────┬──────────┘ + │ + ┌──────────────────────┴──────────────────────┐ + │ │ + │ LOOP │ + │ │ + │ ┌─────────────────────────────────┐ │ + │ │ LLM Node │ │ + │ │ (LLMRunnable) │ │ + │ │ │ │ + │ │ Input: state.messages │ │ + │ │ Output: assistant message │ │ + │ └────────────────┬────────────────┘ │ + │ │ │ + │ ▼ │ + │ ┌─────────────────────────────────┐ │ + │ │ should_continue() │ │ + │ │ │ │ + │ │ - has_tool_calls? → "tools" │ │ + │ │ - no_tool_calls? → "end" │ │ + │ │ - max_iterations? → "end" │ │ + │ └────────────────┬────────────────┘ │ + │ │ │ + │ ┌─────────┴─────────┐ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌─────────────┐ ┌───────────┐ │ + │ │ Tools Node │ │ END │────┼───► OUTPUT + │ │(ToolRunnable│ └───────────┘ │ + │ │ parallel) │ │ + │ └──────┬──────┘ │ + │ │ │ + │ │ (append tool results │ + │ │ to state.messages) │ + │ │ │ + │ └─────────────────────────────┘ + │ │ + └──────────────────────┘ +``` + +## Usage Examples + +### Example 1: Direct AgentRunnable Usage + +```cpp +#include "gopher/orch/agent/agent_runnable.h" + +// Create components +auto provider = createOpenAIProvider("sk-..."); +auto registry = makeToolRegistry(); +registry->addTool("search", "Search the web", schema, searchHandler); + +// Create agent runnable +auto agent = AgentRunnable::create(provider, registry, + AgentConfig("gpt-4o").withMaxIterations(5)); + +// Invoke as Runnable +JsonValue input = JsonValue::object(); +input["query"] = "What is the weather in Tokyo?"; + +agent->invoke(input, RunnableConfig(), dispatcher, + [](Result result) { + if (isSuccess(result)) { + std::cout << getValue(result)["response"].getString() << std::endl; + } + }); +``` + +### Example 2: Agent in Sequence Pipeline + +```cpp +#include "gopher/orch/composition/sequence.h" +#include "gopher/orch/agent/agent_runnable.h" + +// Preprocessing: extract and validate query +auto preprocess = makeJsonLambda([](const JsonValue& input) { + JsonValue output = JsonValue::object(); + output["query"] = sanitize(input["user_input"].getString()); + return makeSuccess(output); +}, "Preprocess"); + +// Postprocessing: format response +auto postprocess = makeJsonLambda([](const JsonValue& input) { + JsonValue output = JsonValue::object(); + output["answer"] = input["response"]; + output["source"] = "AI Assistant"; + return makeSuccess(output); +}, "Postprocess"); + +// Build pipeline +auto pipeline = sequence("AgentPipeline") + .add(preprocess) + .add(AgentRunnable::create(provider, registry)) + .add(postprocess) + .build(); + +// Execute +pipeline->invoke(userInput, config, dispatcher, callback); +``` + +### Example 3: Multi-Agent Router + +```cpp +#include "gopher/orch/composition/router.h" +#include "gopher/orch/agent/agent_runnable.h" + +// Different agents for different tasks +auto codeAgent = AgentRunnable::create(codeProvider, codeTools, + AgentConfig("gpt-4o").withSystemPrompt("You are a coding assistant.")); + +auto researchAgent = AgentRunnable::create(researchProvider, searchTools, + AgentConfig("gpt-4o").withSystemPrompt("You are a research assistant.")); + +auto generalAgent = AgentRunnable::create(provider, {}, + AgentConfig("gpt-4o")); + +// Route based on query type +auto agentRouter = router("AgentRouter") + .when([](const JsonValue& in) { + return in["query"].getString().find("code") != std::string::npos; + }, codeAgent) + .when([](const JsonValue& in) { + return in["query"].getString().find("search") != std::string::npos; + }, researchAgent) + .otherwise(generalAgent) + .build(); + +agentRouter->invoke(input, config, dispatcher, callback); +``` + +### Example 4: Agent in StateGraph Workflow + +```cpp +#include "gopher/orch/graph/state_graph.h" +#include "gopher/orch/agent/agent_runnable.h" + +// Build complex workflow +StateGraph workflow; + +// Add nodes +workflow.addNode("classifier", makeJsonLambda([](const JsonValue& in) { + // Classify the request + JsonValue out = in; + out["category"] = classify(in["query"].getString()); + return makeSuccess(out); +}, "Classifier")); + +workflow.addNode("agent", AgentRunnable::create(provider, tools)); + +workflow.addNode("validator", makeJsonLambda([](const JsonValue& in) { + // Validate agent response + JsonValue out = in; + out["valid"] = validate(in["response"].getString()); + return makeSuccess(out); +}, "Validator")); + +// Add edges +workflow.setEntryPoint("classifier"); +workflow.addConditionalEdge("classifier", [](const GraphState& s) { + return s.get("category").getString() == "complex" ? "agent" : "end"; +}); +workflow.addEdge("agent", "validator"); +workflow.addConditionalEdge("validator", [](const GraphState& s) { + return s.get("valid").getBool() ? "end" : "agent"; // Retry if invalid +}); + +// Compile and run +auto compiled = workflow.compile(); +compiled->invoke(input, config, dispatcher, callback); +``` + +### Example 5: Parallel Multi-Agent + +```cpp +#include "gopher/orch/composition/parallel.h" +#include "gopher/orch/agent/agent_runnable.h" + +// Run multiple specialized agents in parallel +auto multiAgent = parallel("MultiAgentResearch") + .add("web_search", AgentRunnable::create(provider, webSearchTools)) + .add("academic", AgentRunnable::create(provider, academicTools)) + .add("news", AgentRunnable::create(provider, newsTools)) + .build(); + +// Result combines all agent outputs +// {"web_search": {...}, "academic": {...}, "news": {...}} +multiAgent->invoke(input, config, dispatcher, callback); +``` + +### Example 6: Agent with Resilience + +```cpp +#include "gopher/orch/resilience/retry.h" +#include "gopher/orch/resilience/timeout.h" +#include "gopher/orch/agent/agent_runnable.h" + +auto agent = AgentRunnable::create(provider, tools); + +// Add timeout per invocation +auto timedAgent = Timeout::create( + agent, + std::chrono::seconds(60) +); + +// Add retry with exponential backoff +auto resilientAgent = Retry::create( + timedAgent, + RetryPolicy::exponential(3, 1000) // 3 attempts, 1s initial delay +); + +resilientAgent->invoke(input, config, dispatcher, callback); +``` + +## File Structure + +``` +include/gopher/orch/ +├── core/ +│ ├── runnable.h # Base Runnable template +│ ├── lambda.h # Lambda wrapper +│ ├── config.h # RunnableConfig +│ └── types.h # Core types (Result, Error, etc.) +│ +├── llm/ +│ ├── llm_provider.h # LLMProvider interface +│ ├── llm_types.h # Message, ToolCall, LLMResponse +│ ├── llm_runnable.h # NEW: LLMRunnable wrapper +│ ├── openai_provider.h # OpenAI implementation +│ └── anthropic_provider.h # Anthropic implementation +│ +├── agent/ +│ ├── agent.h # Agent interface (direct use) +│ ├── agent_types.h # AgentConfig, AgentResult +│ ├── agent_state.h # NEW: AgentState with reducers +│ ├── agent_runnable.h # NEW: AgentRunnable (composable) +│ ├── tool_registry.h # Tool storage +│ ├── tool_executor.h # Tool execution +│ ├── tool_runnable.h # NEW: ToolRunnable wrapper +│ └── tool_definition.h # Tool types +│ +├── composition/ +│ ├── sequence.h # Sequential composition +│ ├── parallel.h # Parallel composition +│ └── router.h # Conditional routing +│ +├── resilience/ +│ ├── retry.h # Retry wrapper +│ ├── timeout.h # Timeout wrapper +│ ├── circuit_breaker.h # Circuit breaker +│ └── fallback.h # Fallback wrapper +│ +└── graph/ + ├── state_graph.h # StateGraph builder + ├── graph_state.h # GraphState container + ├── graph_node.h # Node types + └── compiled_graph.h # CompiledStateGraph +``` + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Wrapper vs Inheritance | Wrapper (Option A) | C++ single inheritance, type safety, flexibility | +| State Management | AgentState with reducers | Enables parallel tools, clear message history | +| Input/Output Types | JsonValue | Flexible, interoperable with all components | +| Internal Structure | Graph-based | Matches LangGraph, enables complex flows | +| Tool Execution | Parallel by default | Performance, matches LLM batch tool calls | +| Error Handling | Result monad | Consistent with codebase, explicit errors | +| Tool Execution Location | Internal (Option 1) | Simpler execution flow, no context switching | +| Connection Types | Optional enhancement | Useful for visual builders, not required initially | + +## Learnings from n8n + +n8n is a workflow automation platform with strong AI agent integration. Their architecture provides several patterns worth considering. + +### 1. Typed Connection System + +n8n uses `NodeConnectionTypes` to distinguish different connection semantics: + +```typescript +NodeConnectionTypes = { + AiAgent: 'ai_agent', + AiLanguageModel: 'ai_languageModel', + AiMemory: 'ai_memory', + AiTool: 'ai_tool', + AiOutputParser: 'ai_outputParser', + Main: 'main', // regular data flow +} +``` + +This allows nodes to have multiple typed input/output ports. An Agent node can accept: +- `AiLanguageModel` → the LLM connection +- `AiTool` → zero or more tool connections +- `AiMemory` → optional memory connection +- `Main` → trigger/data input + +**Applicable to gopher-orch**: We could add connection type hints for visual graph builders: + +```cpp +enum class ConnectionType { + Main, // Regular data flow + Tool, // Tool connection + Memory, // Memory/state connection + LLM // LLM provider connection +}; + +// Optional: typed edges in CompiledStateGraph +struct TypedEdge { + std::string from_node; + std::string to_node; + ConnectionType type; +}; +``` + +### 2. Engine Request/Response Pattern + +n8n separates tool calls into a request-response cycle: + +``` +Agent Node Engine + │ │ + ├── LLM returns tool calls ───►│ + │◄── EngineRequest (pause) ────┤ + │ │ + │ [Engine executes tool │ + │ nodes in parallel] │ + │ │ + │◄── EngineResponse (resume) ──┤ + ├── Continue with results ────►│ +``` + +**Key insight**: Tools execute *outside* the agent loop as independent nodes, enabling: +- **Tools as visual nodes** that can be connected in the UI +- **Parallel tool execution** at the engine level +- **Tool reusability** across different agents/workflows + +**Design options for gopher-orch**: + +| Option | Approach | Pros | Cons | +|--------|----------|------|------| +| Option 1 (Current) | Tools execute inside agent loop | Simpler, self-contained | Less visual, tools not reusable | +| Option 2 (n8n-style) | Agent yields tool requests | Visual composition, reusable tools | More complex, context switching | + +**Recommendation**: Start with Option 1 (internal execution). Add Option 2 later for visual builder use cases: + +```cpp +// Future: External tool execution mode +struct ToolRequest { + std::string tool_name; + JsonValue arguments; + std::string call_id; +}; + +// Agent can optionally yield pending tool calls +enum class AgentYieldReason { ToolCalls, Complete, Error }; + +struct AgentYield { + AgentYieldReason reason; + std::vector pending_tools; // If reason == ToolCalls + JsonValue result; // If reason == Complete +}; +``` + +### 3. RunnableSequence Composition + +n8n uses LangChain's `RunnableSequence.from([...])` for composing agent internals: + +```typescript +const runnableAgent = RunnableSequence.from([ + fallbackAgent ? agent.withFallbacks([fallbackAgent]) : agent, + getAgentStepsParser(outputParser, memory), + fixEmptyContentMessage, +]); +``` + +This validates our `Sequence<>` pattern for composing processing steps internally. + +### 4. Batching and Fallback + +n8n's `executeBatch` demonstrates: +- Batch processing multiple inputs through the same agent +- Built-in fallback model support +- `continueOnFail` error handling per item + +**Applicable to gopher-orch**: Consider adding to AgentConfig: + +```cpp +struct AgentConfig { + // ... existing fields ... + + // Fallback support (inspired by n8n) + LLMProviderPtr fallback_provider; + + // Batch processing + int batch_size = 1; + std::chrono::milliseconds delay_between_batches{0}; + bool continue_on_fail = false; +}; +``` + +### 5. Versioned Node Types + +n8n maintains backward compatibility via versioned implementations: + +```typescript +nodeVersions = { + 1: new AgentV1(baseDescription), + 2: new AgentV2(baseDescription), + 3: new AgentV3(baseDescription), +} +``` + +**Applicable to gopher-orch**: For production, consider versioning: + +```cpp +// Version in config +struct AgentConfig { + int version = 1; // For serialization compatibility + // ... +}; + +// Or version in class name for breaking changes +class AgentRunnableV2 : public Runnable { ... }; +``` + +### 6. DirectedGraph Operations + +n8n's `WorkflowExecute` uses `DirectedGraph.fromWorkflow(workflow)` for: +- Finding start nodes +- Detecting cycles (`handleCycles`) +- Partial execution (subgraph extraction) +- Dirty node tracking for re-execution + +**Applicable to gopher-orch**: Our `CompiledStateGraph` should support: + +```cpp +class CompiledStateGraph { + // Existing + void invoke(...); + + // Consider adding (inspired by n8n) + std::vector findStartNodes() const; + bool hasCycles() const; + CompiledStateGraph extractSubgraph( + const std::string& from, + const std::string& to) const; + + // Partial execution: re-run from a specific node + void invokePartial( + const std::string& start_node, + const GraphState& existing_state, + Dispatcher& dispatcher, + Callback callback); +}; +``` + +### Adoption Priority + +| Pattern | Priority | Recommendation | +|---------|----------|----------------| +| Typed connections | Low | Add later for visual builders | +| External tool execution | Low | Start internal, add external mode later | +| RunnableSequence composition | Already done | Validates our Sequence pattern | +| Fallback model support | Medium | Add to AgentConfig | +| Batch processing | Medium | Add to AgentConfig | +| Versioning | Medium | Add version field for compatibility | +| Graph operations | Medium | Add partial execution support | + +## Thread Safety + +All components follow the dispatcher-based threading model: + +1. **Invoke**: Called from dispatcher thread +2. **Callbacks**: Always invoked in dispatcher thread context +3. **State**: Not shared across threads; passed through callbacks +4. **Cancellation**: Atomic flag checked at safe points + +```cpp +// Thread safety contract +class AgentRunnable : public Runnable { + // invoke() must be called from dispatcher thread + // callback is always invoked in dispatcher thread + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, // All async work uses this + Callback callback) override; +}; +``` + +## References + +- LangChain Runnable: `langchain-core/runnables/base.py` +- LangGraph Pregel: `langgraph/pregel/main.py` +- LangGraph create_react_agent: `langgraph/prebuilt/chat_agent_executor.py` +- n8n Agent Node: `packages/@n8n/nodes-langchain/nodes/agents/Agent/` +- n8n ToolsAgent Execute: `nodes/agents/Agent/agents/ToolsAgent/V3/execute.ts` +- n8n NodeConnectionTypes: `packages/workflow/src/interfaces.ts:2169` +- n8n WorkflowExecute: `packages/core/src/execution-engine/workflow-execute.ts` +- gopher-orch Runnable: `include/gopher/orch/core/runnable.h` +- gopher-orch Agent: `include/gopher/orch/agent/agent.h` diff --git a/third_party/gopher-orch/docs/Composition.md b/third_party/gopher-orch/docs/Composition.md new file mode 100644 index 00000000..7a779aa8 --- /dev/null +++ b/third_party/gopher-orch/docs/Composition.md @@ -0,0 +1,258 @@ +# Composition Patterns + +Gopher Orch provides three core composition patterns for building complex workflows from simple components: **Sequence**, **Parallel**, and **Router**. + +## Overview + +| Pattern | Purpose | Behavior | +|---------|---------|----------| +| Sequence | Chain operations | Output of A becomes input of B | +| Parallel | Concurrent execution | Same input to all branches, collect results | +| Router | Conditional branching | Route to different handlers based on conditions | + +## Sequence + +Chain multiple runnables together where the output of one becomes the input of the next. + +### Basic Usage + +```cpp +#include "gopher/orch/composition/sequence.h" + +using namespace gopher::orch::composition; + +// Using pipe operator (type-safe) +auto pipeline = parseInput | processData | formatOutput; + +// Using builder (JSON runnables) +auto seq = sequence("MyPipeline") + .add(step1) + .add(step2) + .add(step3) + .build(); + +// Invoke +seq->invoke(input, config, dispatcher, callback); +``` + +### Type-Safe Chaining + +When types are known at compile time, use the `|` operator: + +```cpp +// Types must match: A's output = B's input +auto step1 = makeSyncLambda(...); // string -> int +auto step2 = makeSyncLambda(...); // int -> JsonValue + +auto pipeline = step1 | step2; // string -> JsonValue +``` + +### Dynamic Chaining + +For runtime-composed pipelines, use the builder: + +```cpp +auto builder = sequence("DynamicPipeline"); + +for (auto& step : steps) { + builder.add(step); +} + +auto pipeline = builder.build(); +``` + +### Error Handling + +Sequence **short-circuits on first error** - subsequent steps are not executed: + +```cpp +auto seq = sequence() + .add(mayFail) // If this fails... + .add(neverRuns) // ...this is skipped + .build(); +``` + +## Parallel + +Execute multiple runnables concurrently with the same input. + +### Basic Usage + +```cpp +#include "gopher/orch/composition/parallel.h" + +using namespace gopher::orch::composition; + +// Build parallel execution +auto par = parallel("FetchAll") + .add("weather", fetchWeather) + .add("news", fetchNews) + .add("stocks", fetchStocks) + .build(); + +// Invoke - all branches get the same input +par->invoke(input, config, dispatcher, [](Result result) { + // Result is an object with keys: weather, news, stocks + auto& data = mcp::get(result); + auto weather = data["weather"]; + auto news = data["news"]; + auto stocks = data["stocks"]; +}); +``` + +### Result Structure + +Results are collected into a JSON object with branch keys: + +```json +{ + "weather": { "temp": 72, "condition": "sunny" }, + "news": [ { "title": "..." }, ... ], + "stocks": { "AAPL": 150.00, ... } +} +``` + +### Fail-Fast Behavior + +By default, Parallel uses **fail-fast** semantics: +- First error cancels pending branches +- Error is returned immediately + +```cpp +auto par = parallel() + .add("fast", quickOp) // Completes first + .add("slow", slowOp) // If fast fails, slow is cancelled + .build(); +``` + +## Router + +Route input to different runnables based on conditions. + +### Basic Usage + +```cpp +#include "gopher/orch/composition/router.h" + +using namespace gopher::orch::composition; + +// JSON router with conditions +auto route = router("ActionRouter") + .when([](const JsonValue& input) { + return input["action"].getString() == "search"; + }, searchHandler) + .when([](const JsonValue& input) { + return input["action"].getString() == "calculate"; + }, calculateHandler) + .otherwise(defaultHandler) + .build(); + +// Invoke - routes to matching handler +route->invoke(input, config, dispatcher, callback); +``` + +### Type-Safe Router + +For typed runnables: + +```cpp +auto route = makeRouter("TypedRouter") + .when([](const std::string& s) { return s.starts_with("http"); }, httpHandler) + .when([](const std::string& s) { return s.starts_with("file"); }, fileHandler) + .otherwise(defaultHandler) + .build(); +``` + +### Condition Evaluation + +Conditions are evaluated in order: +1. First matching condition wins +2. If no match, uses `otherwise` handler +3. If no `otherwise`, returns error + +```cpp +auto route = router() + .when(isHighPriority, fastPath) // Checked first + .when(isNormalPriority, normalPath) // Checked second + .otherwise(slowPath) // Fallback + .build(); +``` + +## Combining Patterns + +Patterns can be nested and combined: + +```cpp +// Sequence with parallel step +auto pipeline = sequence() + .add(parseInput) + .add(parallel() + .add("validate", validator) + .add("enrich", enricher) + .build()) + .add(processResults) + .build(); + +// Router with sequence branches +auto workflow = router() + .when(isSimple, simpleHandler) + .when(isComplex, sequence() + .add(analyze) + .add(process) + .add(format) + .build()) + .otherwise(errorHandler) + .build(); +``` + +## With Resilience Patterns + +Add reliability to composed workflows: + +```cpp +#include "gopher/orch/resilience/retry.h" +#include "gopher/orch/resilience/timeout.h" + +// Parallel with timeout +auto bounded = withTimeout( + parallel() + .add("api1", fetchFromApi1) + .add("api2", fetchFromApi2) + .build(), + 5000 // 5 second timeout for entire parallel execution +); + +// Sequence with retry +auto reliable = withRetry( + sequence() + .add(fetchData) + .add(processData) + .build(), + RetryPolicy::exponential(3) +); +``` + +## Factory Functions + +| Function | Description | +|----------|-------------| +| `sequence(name)` | Create Sequence builder | +| `parallel(name)` | Create Parallel builder | +| `router(name)` | Create JSON Router builder | +| `makeRouter(name)` | Create typed Router builder | +| `makeSequence(a, b)` | Create type-safe two-step Sequence | +| `a \| b` | Pipe operator for type-safe chaining | + +## Best Practices + +1. **Name your compositions** - Use descriptive names for debugging +2. **Keep branches independent** - Parallel branches shouldn't depend on each other +3. **Handle errors at boundaries** - Use resilience wrappers where appropriate +4. **Consider timeouts** - Long-running compositions should have timeouts +5. **Test branches individually** - Unit test each component before composing + +## See Also + +- [Runnable Interface](Runnable.md) - Core interface +- [Resilience Patterns](Resilience.md) - Retry, Timeout, Fallback, CircuitBreaker +- [StateGraph Guide](StateGraph.md) - Stateful workflows with conditional edges diff --git a/third_party/gopher-orch/docs/FFI.md b/third_party/gopher-orch/docs/FFI.md new file mode 100644 index 00000000..e2515801 --- /dev/null +++ b/third_party/gopher-orch/docs/FFI.md @@ -0,0 +1,414 @@ +# FFI Guide + +Gopher Orch provides a stable C API (FFI layer) for integration with other programming languages. Build agents in Python, Rust, Go, or any language with C FFI support. + +## Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ Your Application │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Python │ │ Rust │ │ Go │ │ Node.js │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ └──────────┴──────────┴──────────┘ │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ │ Language Bindings │ │ +│ └──────────┬──────────┘ │ +├─────────────────────────┼───────────────────────────────┤ +│ ┌──────────┴──────────┐ │ +│ │ C API (FFI Layer) │ │ +│ │ libgopher_orch_c │ │ +│ └──────────┬──────────┘ │ +├─────────────────────────┼───────────────────────────────┤ +│ ┌──────────┴──────────┐ │ +│ │ Gopher Orch C++ │ │ +│ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## C API Design + +The C API uses: +- **Opaque handles** - Hide C++ implementation details +- **RAII guards** - Automatic resource cleanup +- **Error codes** - Explicit error handling +- **Callbacks** - Async operation support + +### Handle Types + +```c +// Opaque handle types +typedef struct gopher_orch_agent* gopher_orch_agent_t; +typedef struct gopher_orch_registry* gopher_orch_registry_t; +typedef struct gopher_orch_provider* gopher_orch_provider_t; +typedef struct gopher_orch_runnable* gopher_orch_runnable_t; +``` + +### Error Handling + +```c +// Error structure +typedef struct { + int code; + const char* message; +} gopher_orch_error_t; + +// Check for errors +gopher_orch_error_t err; +if (gopher_orch_agent_invoke(agent, input, &err) != 0) { + printf("Error %d: %s\n", err.code, err.message); + gopher_orch_error_free(&err); +} +``` + +## Building the C API + +```bash +# Build with C API enabled (default) +cmake -B build -DBUILD_C_API=ON +make -C build + +# Output: lib/libgopher_orch_c.{so,dylib,dll} +# Headers: include/gopher-orch/ffi/ +``` + +## Python Bindings + +### Installation + +```bash +pip install gopher-orch +``` + +### Basic Usage + +```python +from gopher_orch import Agent, ToolRegistry, OpenAIProvider + +# Create provider +provider = OpenAIProvider(api_key="sk-...") + +# Create registry with tools +registry = ToolRegistry() + +@registry.tool("search", "Search the web") +def search(query: str) -> dict: + return {"results": [...]} + +@registry.tool("calculate", "Perform calculations") +def calculate(expression: str) -> float: + return eval(expression) + +# Create agent +agent = Agent( + provider=provider, + registry=registry, + system_prompt="You are a helpful assistant." +) + +# Run agent +result = agent.invoke("What's 2+2 and search for weather in Tokyo") +print(result.response) +``` + +### Async Support + +```python +import asyncio +from gopher_orch import AsyncAgent + +async def main(): + agent = AsyncAgent(provider, registry) + + # Async invocation + result = await agent.invoke("Search for news") + + # Streaming + async for chunk in agent.stream("Tell me a story"): + print(chunk, end="", flush=True) + +asyncio.run(main()) +``` + +## Rust Bindings + +### Cargo.toml + +```toml +[dependencies] +gopher-orch = "0.1" +``` + +### Usage + +```rust +use gopher_orch::{Agent, ToolRegistry, OpenAIProvider}; + +fn main() -> Result<(), Box> { + // Create provider + let provider = OpenAIProvider::new("sk-...")?; + + // Create registry + let mut registry = ToolRegistry::new(); + + registry.add_tool("search", "Search the web", |args| { + let query = args.get("query").as_str()?; + Ok(json!({"results": search_web(query)})) + })?; + + // Create agent + let agent = Agent::builder() + .provider(provider) + .registry(registry) + .system_prompt("You are helpful.") + .build()?; + + // Run agent + let result = agent.invoke("Search for weather")?; + println!("{}", result.response); + + Ok(()) +} +``` + +## Go Bindings + +### Installation + +```bash +go get github.com/anthropics/gopher-orch-go +``` + +### Usage + +```go +package main + +import ( + "fmt" + orch "github.com/anthropics/gopher-orch-go" +) + +func main() { + // Create provider + provider := orch.NewOpenAIProvider("sk-...") + + // Create registry + registry := orch.NewToolRegistry() + + registry.AddTool("search", "Search the web", func(args orch.JSON) (orch.JSON, error) { + query := args.GetString("query") + return searchWeb(query), nil + }) + + // Create agent + agent := orch.NewAgent(provider, registry, orch.AgentConfig{ + SystemPrompt: "You are helpful.", + }) + + // Run agent + result, err := agent.Invoke("Search for news") + if err != nil { + panic(err) + } + fmt.Println(result.Response) +} +``` + +## Node.js Bindings + +### Installation + +```bash +npm install gopher-orch +``` + +### Usage + +```javascript +const { Agent, ToolRegistry, OpenAIProvider } = require('gopher-orch'); + +async function main() { + // Create provider + const provider = new OpenAIProvider({ apiKey: 'sk-...' }); + + // Create registry + const registry = new ToolRegistry(); + + registry.addTool('search', 'Search the web', async (args) => { + const results = await searchWeb(args.query); + return { results }; + }); + + // Create agent + const agent = new Agent({ + provider, + registry, + systemPrompt: 'You are helpful.' + }); + + // Run agent + const result = await agent.invoke('Search for weather'); + console.log(result.response); +} + +main(); +``` + +## C API Reference + +### Agent Functions + +```c +// Create agent +gopher_orch_agent_t gopher_orch_agent_create( + gopher_orch_provider_t provider, + gopher_orch_registry_t registry, + const char* config_json +); + +// Invoke agent (blocking) +int gopher_orch_agent_invoke( + gopher_orch_agent_t agent, + const char* input_json, + char** output_json, + gopher_orch_error_t* error +); + +// Invoke agent (async) +int gopher_orch_agent_invoke_async( + gopher_orch_agent_t agent, + const char* input_json, + gopher_orch_callback_t callback, + void* user_data +); + +// Destroy agent +void gopher_orch_agent_destroy(gopher_orch_agent_t agent); +``` + +### Registry Functions + +```c +// Create registry +gopher_orch_registry_t gopher_orch_registry_create(void); + +// Add tool +int gopher_orch_registry_add_tool( + gopher_orch_registry_t registry, + const char* name, + const char* description, + const char* schema_json, + gopher_orch_tool_fn callback, + void* user_data +); + +// Destroy registry +void gopher_orch_registry_destroy(gopher_orch_registry_t registry); +``` + +### Provider Functions + +```c +// Create OpenAI provider +gopher_orch_provider_t gopher_orch_openai_create( + const char* api_key, + const char* model +); + +// Create Anthropic provider +gopher_orch_provider_t gopher_orch_anthropic_create( + const char* api_key, + const char* model +); + +// Destroy provider +void gopher_orch_provider_destroy(gopher_orch_provider_t provider); +``` + +## Memory Management + +### RAII Guards + +The C API provides RAII-style guards for automatic cleanup: + +```c +// C++ style RAII (if available) +#include + +void example() { + GOPHER_ORCH_GUARD(agent, gopher_orch_agent_create(...)); + // agent automatically destroyed when scope exits +} +``` + +### Manual Cleanup + +```c +gopher_orch_agent_t agent = gopher_orch_agent_create(...); +// ... use agent ... +gopher_orch_agent_destroy(agent); +``` + +## Thread Safety + +- All FFI functions are thread-safe +- Callbacks may be invoked from different threads +- Use the dispatcher model for coordination + +```c +// Thread-safe invocation +gopher_orch_agent_invoke_async(agent, input, + on_complete_callback, user_data); + +// Callback may be called from any thread +void on_complete_callback(const char* result, void* user_data) { + // Handle result thread-safely +} +``` + +## Error Codes + +```c +#define GOPHER_ORCH_OK 0 +#define GOPHER_ORCH_ERR_NULL_PTR -1 +#define GOPHER_ORCH_ERR_INVALID -2 +#define GOPHER_ORCH_ERR_TIMEOUT -3 +#define GOPHER_ORCH_ERR_INTERNAL -4 +``` + +## Best Practices + +1. **Always check errors** - Every FFI call can fail +2. **Free resources** - Call destroy functions or use guards +3. **Copy strings** - FFI strings may be freed after call returns +4. **Use async APIs** - Avoid blocking the main thread +5. **Handle callbacks safely** - They may come from any thread + +## Building Custom Bindings + +For unsupported languages, use the C API directly: + +```c +// 1. Load library +void* lib = dlopen("libgopher_orch_c.so", RTLD_NOW); + +// 2. Get function pointers +typedef gopher_orch_agent_t (*create_fn)(/* ... */); +create_fn create = dlsym(lib, "gopher_orch_agent_create"); + +// 3. Call functions +gopher_orch_agent_t agent = create(/* ... */); + +// 4. Cleanup +gopher_orch_agent_destroy(agent); +dlclose(lib); +``` + +## See Also + +- [Runnable Interface](Runnable.md) - Core C++ interface +- [Agent Framework](Agent.md) - Agent implementation details +- [Server Abstraction](Server.md) - Protocol support diff --git a/third_party/gopher-orch/docs/GatewayServer.md b/third_party/gopher-orch/docs/GatewayServer.md new file mode 100644 index 00000000..76cbf3c3 --- /dev/null +++ b/third_party/gopher-orch/docs/GatewayServer.md @@ -0,0 +1,1080 @@ +# GatewayServer Documentation + +## Overview + +**GatewayServer** is an MCP (Model Context Protocol) server that acts as a gateway/proxy, aggregating and exposing tools from multiple backend MCP servers through a single unified endpoint. This allows clients to interact with tools from different servers as if they were all provided by one server. + +### Key Concepts + +- **Gateway Server**: The front-facing MCP server that external clients connect to +- **Backend Servers**: Multiple MCP servers (running on different ports/processes) that provide tools +- **Tool Aggregation**: The gateway discovers all tools from backend servers and exposes them through its own endpoint +- **Transparent Routing**: Tool calls are automatically routed to the appropriate backend server +- **Connection Management**: Automatic reconnection and connection pooling for backend servers + +--- + +## Architecture + +``` +External MCP Clients (e.g., Claude Desktop, ReActAgent) + | + | HTTP/SSE + v + +-------------------------+ + | GatewayServer | (Port 3003) + | (MCP Server) | + | | + | ServerComposite | + | (Tool Registry) | + +-------------------------+ + | + +-----------+-----------+ + | | + v v + Backend Server 1 Backend Server 2 + (Port 3001) (Port 3002) + - weather tools - auth tools + - time tools - user tools +``` + +### Components + +1. **GatewayServer**: The main server class that: + - Listens on a configurable port (default: 3003) + - Registers all tools from backend servers + - Routes tool calls to the appropriate backend + +2. **ServerComposite**: Internal component that: + - Manages connections to backend servers + - Maintains a unified tool registry + - Handles tool routing and execution + +3. **Backend Servers**: Individual MCP servers that: + - Provide specific tools (e.g., weather, auth, database) + - Can be added or removed dynamically + - Run independently on different ports or as stdio processes + +--- + +## Use Cases + +### 1. **Microservices Architecture** +Split your tool implementations across multiple specialized services: +- `weather-service` on port 3001 (weather, location tools) +- `auth-service` on port 3002 (authentication, user management) +- `database-service` on port 3003 (data access tools) + +The gateway exposes all tools through a single endpoint, simplifying client configuration. + +### 2. **Multi-Language Tool Ecosystem** +Combine tools written in different languages: +- Python service for data science tools +- Node.js service for web scraping +- C++ service for high-performance computation + +### 3. **Development and Testing** +- Test tool implementations in isolation while exposing them through a unified interface +- Gradually migrate tools from monolithic to microservices architecture +- Enable A/B testing by routing to different backend implementations + +### 4. **Connection Pooling and Load Balancing** +- Single connection point for clients (reduces connection overhead) +- Backend connections are maintained with automatic reconnection +- Efficient resource utilization + +--- + +## How It Works + +### Initialization Flow + +1. **Gateway Creation**: + ```cpp + auto gateway = GatewayServer::create(serverJson, config); + ``` + - Parses JSON configuration containing backend server endpoints + - Creates a ServerComposite to manage backend servers + +2. **Backend Connection**: + - Gateway creates an MCP client for each backend server + - Connects to backends (HTTP/SSE or stdio) + - Queries each backend for available tools using `tools/list` + +3. **Tool Registration**: + - Collects all tool definitions (name, description, schema) from backends + - Registers unified tool handlers on the gateway's MCP server + - Maintains mapping of tool names to backend servers + +4. **Server Startup**: + ```cpp + gateway->listen(3003); // Start and block + ``` + - Starts MCP server on configured port + - Accepts client connections + - Blocks until shutdown (Ctrl+C) + +### Request Flow + +``` +1. Client calls tool via Gateway + ↓ +2. Gateway receives tools/call request + ↓ +3. Gateway looks up which backend owns the tool + ↓ +4. Gateway routes call to backend MCP client + ↓ +5. Backend executes tool and returns result + ↓ +6. Gateway forwards result to client +``` + +### Thread Safety + +- **Gateway Server**: Thread-safe, can handle multiple concurrent clients +- **Tool Execution**: Each tool call runs in the backend's dispatcher thread +- **Synchronization**: Uses mutex and condition variables for blocking tool calls +- **Shutdown**: Graceful shutdown on SIGINT/SIGTERM, forced exit on second signal + +--- + +## API Documentation + +### Simple API (Recommended for Most Use Cases) + +#### `GatewayServer::create(serverJson, config)` + +Creates a gateway server from JSON configuration. + +**Parameters:** +- `serverJson` (string): JSON configuration of backend servers +- `config` (GatewayServerConfig, optional): Server configuration + +**Returns:** `GatewayServerPtr` (shared_ptr) + +**Example:** +```cpp +// Manifest format with metadata, config, and servers +std::string serverJson = R"({ + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01" + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2 + } + }, + "servers": [ + { + "serverId": "1877234567890123456", + "name": "weather-server", + "url": "http://127.0.0.1:3001/mcp" + }, + { + "serverId": "1877234567890123457", + "name": "auth-server", + "url": "http://127.0.0.1:3002/mcp" + } + ] +})"; + +// API response format is also supported: +// {"succeeded": true, "data": { }} + +auto gateway = GatewayServer::create(serverJson); +if (!gateway->getError().empty()) { + std::cerr << "Error: " << gateway->getError() << std::endl; + return 1; +} +``` + +#### `listen(port)` + +Starts the gateway server and blocks until shutdown. + +**Parameters:** +- `port` (int): Port to listen on + +**Returns:** `int` (0 on success, non-zero on error) + +**Example:** +```cpp +gateway->listen(3003); // Blocks until Ctrl+C +``` + +### Advanced API (For Fine-Grained Control) + +#### `GatewayServer::create(composite, config)` + +Creates a gateway server with an existing ServerComposite. + +**Parameters:** +- `composite` (ServerCompositePtr): Pre-configured server composite +- `config` (GatewayServerConfig, optional): Server configuration + +**Returns:** `GatewayServerPtr` + +**Example:** +```cpp +// Create composite manually +auto composite = ServerComposite::create("my-composite"); + +// Configure and add servers manually +MCPServerConfig server1_config; +server1_config.name = "weather-server"; +server1_config.transport_type = MCPServerConfig::TransportType::HTTP_SSE; +server1_config.http_sse_transport.url = "http://127.0.0.1:3001/mcp"; + +// Create and connect server asynchronously +MCPServer::create(server1_config, dispatcher, [&](Result result) { + if (mcp::holds_alternative(result)) { + auto server1 = mcp::get(result); + std::cout << "Server 1 connected" << std::endl; + composite->addServer(server1, {"weather", "time"}, false); + } +}); + +// Create gateway with composite +GatewayServerConfig config; +config.port = 3003; +auto gateway = GatewayServer::create(composite, config); +``` + +#### `start(dispatcher, callback)` + +Starts the gateway server asynchronously. + +**Parameters:** +- `dispatcher` (Dispatcher&): Event dispatcher for async operations +- `callback` (function): Called when server starts + +**Example:** +```cpp +gateway->start(dispatcher, [](VoidResult result) { + if (mcp::holds_alternative(result)) { + std::cout << "Gateway started successfully" << std::endl; + } else { + auto error = mcp::get(result); + std::cerr << "Gateway failed: " << error.message << std::endl; + } +}); +``` + +#### `stop(dispatcher, callback)` / `stop()` + +Stops the gateway server. + +**Parameters:** +- `dispatcher` (Dispatcher&, optional): For async stop +- `callback` (function, optional): Called when server stops + +**Example:** +```cpp +// Async stop +gateway->stop(dispatcher, []() { + std::cout << "Gateway stopped" << std::endl; +}); + +// Blocking stop +gateway->stop(); +``` + +### Accessor Methods + +```cpp +// Get server name +const std::string& name() const; + +// Get underlying ServerComposite +ServerCompositePtr getComposite() const; + +// Check if server is running +bool isRunning() const; + +// Get listen address (e.g., "0.0.0.0:3003") +std::string getListenAddress() const; + +// Get listen URL (e.g., "http://0.0.0.0:3003") +std::string getListenUrl() const; + +// Get number of registered tools +size_t toolCount() const; + +// Get number of connected backend servers +size_t serverCount() const; + +// Get error message if creation failed +const std::string& getError() const; +``` + +--- + +## Configuration + +### `GatewayServerConfig` Structure + +```cpp +struct GatewayServerConfig { + std::string name = "gateway-server"; // Server name + std::string host = "0.0.0.0"; // Listen host + int port = 3003; // Listen port + int workers = 4; // Worker threads + int max_sessions = 100; // Max concurrent sessions + std::chrono::milliseconds session_timeout{300000}; // 5 minutes + std::chrono::milliseconds request_timeout{30000}; // 30 seconds + + // HTTP/SSE endpoint paths + std::string http_rpc_path = "/mcp"; // RPC endpoint + std::string http_sse_path = "/events"; // SSE endpoint + std::string http_health_path = "/health"; // Health check +}; +``` + +### Backend Server JSON Format + +The gateway supports two JSON formats: + +#### Manifest Format (Config File or Environment Variable) + +```json +{ + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01", + "generatedAt": 1768114552523 + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2, + "retryableCodes": [429, 500, 502, 503, 504] + } + }, + "servers": [ + { + "serverId": "1877234567890123456", + "version": "2025-01-09", + "name": "server-name", + "url": "http://127.0.0.1:3001/mcp" + } + ] +} +``` + +#### API Response Format (with succeeded/data wrapper) + +```json +{ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "version": "2026-01-11", + "metadata": { ... }, + "config": { ... }, + "servers": [ ... ] + } +} +``` + +### Server Object Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `serverId` | string | Yes | Unique server identifier | +| `version` | string | No | Server configuration version | +| `name` | string | Yes | Server display name | +| `url` | string | Yes* | HTTP/SSE endpoint URL | +| `command` | string | Yes* | Stdio command (alternative to url) | +| `args` | array | No | Command arguments (for stdio) | + +*Either `url` (HTTP/SSE) or `command` (stdio) is required. + +### Transport Auto-Detection + +The gateway automatically detects transport type: + +#### HTTP/SSE Transport (server has `url` field) +```json +{ + "serverId": "123", + "name": "remote-server", + "url": "http://example.com:3001/mcp" +} +``` + +#### Stdio Transport (server has `command` field) +```json +{ + "serverId": "456", + "name": "local-tool", + "command": "python", + "args": ["-m", "my_tool_server"] +} +``` + +--- + +## Examples + +### Example 1: Basic Gateway Setup + +**File: `gateway_example.cpp`** + +```cpp +#include +#include "gopher/orch/server/gateway_server.h" + +using namespace gopher::orch::server; + +int main() { + std::cout << "Starting GatewayServer..." << std::endl; + + // Configure backend servers with manifest format + std::string serverJson = R"({ + "version": "2026-01-11", + "metadata": { + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01" + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000 + }, + "servers": [ + { + "serverId": "1877234567890123456", + "name": "weather-service", + "url": "http://127.0.0.1:3001/mcp" + }, + { + "serverId": "1877234567890123457", + "name": "auth-service", + "url": "http://127.0.0.1:3002/mcp" + } + ] + })"; + + // Create gateway + auto gateway = GatewayServer::create(serverJson); + + if (!gateway->getError().empty()) { + std::cerr << "Failed to create gateway: " + << gateway->getError() << std::endl; + return 1; + } + + std::cout << "Gateway created successfully" << std::endl; + std::cout << "Registered " << gateway->toolCount() << " tools" << std::endl; + std::cout << "Connected to " << gateway->serverCount() << " servers" << std::endl; + std::cout << "Listening on " << gateway->getListenUrl() << std::endl; + + // Start listening (blocks until Ctrl+C) + return gateway->listen(3003); +} +``` + +### Example 2: Custom Configuration + +```cpp +#include "gopher/orch/server/gateway_server.h" + +int main() { + std::string serverJson = "..."; // Backend configuration + + // Custom gateway configuration + GatewayServerConfig config; + config.name = "my-gateway"; + config.host = "127.0.0.1"; // Localhost only + config.port = 8080; + config.workers = 8; + config.max_sessions = 200; + config.session_timeout = std::chrono::minutes(10); + config.request_timeout = std::chrono::seconds(60); + config.http_rpc_path = "/api/mcp"; + config.http_sse_path = "/api/events"; + + auto gateway = GatewayServer::create(serverJson, config); + return gateway->listen(config.port); +} +``` + +### Example 3: Dynamic Server Management + +```cpp +#include "gopher/orch/server/gateway_server.h" +#include "gopher/orch/server/server_composite.h" +#include "gopher/orch/server/mcp_server.h" +#include "mcp/event/libevent_dispatcher.h" + +int main() { + // Create composite manually + auto composite = ServerComposite::create("dynamic-gateway"); + + // Create dispatcher + mcp::event::LibeventDispatcher dispatcher("gateway"); + + // Add server 1 + MCPServerConfig config1; + config1.name = "server1"; + config1.transport_type = MCPServerConfig::TransportType::HTTP_SSE; + config1.http_sse_transport.url = "http://localhost:3001/mcp"; + + MCPServer::create(config1, dispatcher, [&](Result result) { + if (mcp::holds_alternative(result)) { + auto server1 = mcp::get(result); + + // Discover tools + server1->listTools(dispatcher, [&, server1](auto tools_result) { + if (!isError>(tools_result)) { + auto tools = mcp::get>(tools_result); + std::vector tool_names; + for (const auto& tool : tools) { + tool_names.push_back(tool.name); + } + + // Add to composite + composite->addServer(server1, tool_names, false); + std::cout << "Added server1 with " << tool_names.size() + << " tools" << std::endl; + } + }); + } + }); + + // Run dispatcher to process async operations + dispatcher.run(mcp::event::RunType::NonBlock); + + // Create gateway with composite + auto gateway = GatewayServer::create(composite); + + // Later: dynamically add another server + // composite->addServer(server2, tool_names2, false); + + return gateway->listen(3003); +} +``` + +### Example 4: Testing Gateway with Client + +**File: `gateway_client_test.cpp`** + +```cpp +#include +#include "gopher/orch/agent/agent.h" + +using namespace gopher::orch::agent; + +int main() { + std::cout << "=== Gateway Client Test ===" << std::endl; + + // Connect to gateway (not backend servers directly) + std::string gatewayConfig = R"({ + "version": "2026-01-11", + "metadata": { + "gatewayId": "client-test" + }, + "config": {}, + "servers": [ + { + "serverId": "1", + "name": "gateway", + "url": "http://127.0.0.1:3003/mcp" + } + ] + })"; + + // Create agent connected to gateway + auto agent = ReActAgent::createByJson( + "AnthropicProvider", + "claude-3-5-sonnet-20241022", + gatewayConfig + ); + + if (!agent) { + std::cerr << "Failed to create agent" << std::endl; + return 1; + } + + std::cout << "Agent connected to gateway" << std::endl; + + // Test queries that use tools from different backends + std::vector queries = { + "What is the weather in Tokyo?", // weather-service tool + "List all registered users", // auth-service tool + "What time is it in New York?" // time-service tool + }; + + for (const auto& query : queries) { + std::cout << "\nQuery: " << query << std::endl; + std::string answer = agent->run(query); + std::cout << "Answer: " << answer << std::endl; + } + + return 0; +} +``` + +--- + +## Features and Benefits + +### Key Features + +1. **Tool Aggregation** + - Automatic discovery of tools from all backend servers + - Unified tool registry accessible through single endpoint + - Preserves tool metadata (descriptions, schemas, examples) + +2. **Transparent Routing** + - Automatic routing of tool calls to correct backend + - No client-side knowledge of backend topology required + - Support for duplicate tool names (last-registered wins) + +3. **Connection Management** + - Automatic reconnection to backend servers + - Connection pooling and reuse + - Idle timeout detection (4-second threshold) + - Retry logic with exponential backoff (max 50 retries × 10ms) + +4. **Transport Flexibility** + - HTTP/SSE transport for remote servers + - Stdio transport for local processes + - Mixed transport types in same gateway + +5. **Robustness** + - Graceful shutdown on SIGINT/SIGTERM + - Forced exit on second signal + - Backend failure isolation (one backend failure doesn't affect others) + - Comprehensive debug logging + +6. **Performance** + - Non-blocking I/O with libevent + - Multi-threaded request handling + - Efficient connection reuse + - Configurable worker threads + +### Benefits + +#### For Developers +- **Modularity**: Split tool implementations into focused services +- **Scalability**: Scale backend services independently +- **Language Freedom**: Use different languages for different tools +- **Testing**: Test services in isolation +- **Development Velocity**: Teams can work on different backends independently + +#### For Clients +- **Simplicity**: Single endpoint to connect to +- **Consistency**: Uniform interface across all tools +- **Performance**: Connection pooling reduces overhead +- **Reliability**: Automatic reconnection and retry logic + +#### For Operations +- **Monitoring**: Centralized access logs and metrics +- **Deployment**: Independent deployment of backend services +- **Load Balancing**: Route to different backends for load distribution +- **Security**: Single point for authentication/authorization + +--- + +## Best Practices + +### 1. Backend Server Organization + +``` +Organize by domain/functionality: + +✓ GOOD: Domain-based separation + - weather-service (weather, location, time zone) + - auth-service (users, sessions, permissions) + - data-service (database, cache, search) + +✗ AVOID: Fine-grained separation + - tool1-service (single tool) + - tool2-service (single tool) + - tool3-service (single tool) +``` + +### 2. Error Handling + +```cpp +// Always check for creation errors +auto gateway = GatewayServer::create(serverJson); +if (!gateway->getError().empty()) { + LOG_ERROR("Gateway creation failed: " << gateway->getError()); + // Handle error appropriately + return 1; +} + +// Check connection status +if (gateway->serverCount() == 0) { + LOG_WARNING("No backend servers connected"); +} + +// Log tool registration +LOG_INFO("Gateway registered " << gateway->toolCount() << " tools " + << "from " << gateway->serverCount() << " servers"); +``` + +### 3. Configuration Management + +```cpp +// Load configuration from file +std::string loadServerConfig(const std::string& filename) { + std::ifstream file(filename); + if (!file.is_open()) { + throw std::runtime_error("Failed to open " + filename); + } + return std::string( + std::istreambuf_iterator(file), + std::istreambuf_iterator() + ); +} + +// Use environment-specific configs +std::string env = std::getenv("ENVIRONMENT") ?: "development"; +std::string configFile = "config." + env + ".json"; +std::string serverJson = loadServerConfig(configFile); + +auto gateway = GatewayServer::create(serverJson); +``` + +### 4. Monitoring and Logging + +```cpp +// Enable debug logging +export GOPHER_LOG_LEVEL=debug + +// Log gateway state periodically +void logGatewayStats(GatewayServerPtr gateway) { + LOG_INFO("Gateway Stats:"); + LOG_INFO(" Running: " << gateway->isRunning()); + LOG_INFO(" Tools: " << gateway->toolCount()); + LOG_INFO(" Servers: " << gateway->serverCount()); + LOG_INFO(" Address: " << gateway->getListenUrl()); +} +``` + +### 5. Graceful Shutdown + +```cpp +// The gateway handles SIGINT/SIGTERM automatically +// For custom cleanup: + +class Application { + GatewayServerPtr gateway_; + + void setupSignalHandlers() { + signal(SIGINT, [](int) { + LOG_INFO("Shutdown requested..."); + // Gateway auto-shuts down + // Add custom cleanup here + }); + } + + int run() { + setupSignalHandlers(); + return gateway_->listen(3003); + } +}; +``` + +### 6. Testing Strategy + +```cpp +// Unit test: Mock ServerComposite +TEST(GatewayServerTest, CreatesFromComposite) { + auto composite = ServerComposite::create("test"); + auto gateway = GatewayServer::create(composite); + EXPECT_TRUE(gateway != nullptr); +} + +// Integration test: Real backend servers +TEST(GatewayServerTest, ConnectsToBackends) { + // Start real backend servers on test ports + startTestServer(4001, {"tool1", "tool2"}); + startTestServer(4002, {"tool3", "tool4"}); + + std::string config = makeConfig({ + {"server1", "http://localhost:4001/mcp"}, + {"server2", "http://localhost:4002/mcp"} + }); + + auto gateway = GatewayServer::create(config); + EXPECT_EQ(2, gateway->serverCount()); + EXPECT_EQ(4, gateway->toolCount()); +} + +// End-to-end test: Full client workflow +TEST(GatewayServerTest, ClientCanCallTools) { + auto gateway = startGatewayWithBackends(); + auto client = createTestClient("http://localhost:3003/mcp"); + + auto result = client->callTool("tool1", {}); + EXPECT_TRUE(result.succeeded); +} +``` + +--- + +## Troubleshooting + +### Common Issues + +#### 1. Gateway Creation Fails + +**Symptom:** +```cpp +auto gateway = GatewayServer::create(serverJson); +if (!gateway->getError().empty()) { + std::cerr << gateway->getError() << std::endl; + // "No 'servers' array found in configuration" +} +``` + +**Solutions:** +- Verify JSON is valid: `echo $JSON | jq .` +- Check JSON structure has `data.servers` array +- Ensure at least one server is configured +- Validate server URLs are accessible + +#### 2. No Tools Registered + +**Symptom:** +```cpp +std::cout << gateway->toolCount() << std::endl; // Prints: 0 +``` + +**Solutions:** +- Check backend servers are running +- Verify backend URLs are correct +- Check network connectivity: `curl http://localhost:3001/mcp` +- Enable debug logging: `export GOPHER_LOG_LEVEL=debug` +- Check backend servers implement `tools/list` method + +#### 3. Tool Calls Timeout + +**Symptom:** +Client calls tool, but request times out after 30 seconds. + +**Solutions:** +- Check backend server is responsive: `curl -X POST http://localhost:3001/mcp` +- Increase request timeout in configuration: + ```cpp + GatewayServerConfig config; + config.request_timeout = std::chrono::seconds(60); + ``` +- Check backend logs for errors +- Verify tool implementation doesn't hang + +#### 4. Connection Refused + +**Symptom:** +``` +Error: Connection refused to http://localhost:3001/mcp +``` + +**Solutions:** +- Start backend servers before gateway +- Check port numbers match configuration +- Verify firewall rules allow connections +- Check backend is listening: `netstat -an | grep 3001` + +#### 5. Duplicate Tool Names + +**Symptom:** +Multiple backends provide same tool name, only one works. + +**Behavior:** +Last-registered backend wins for duplicate tool names. + +**Solutions:** +- Use unique tool names across backends +- Or: Accept last-registered wins behavior +- Or: Namespace tools by backend: `server1.tool`, `server2.tool` + +#### 6. Gateway Won't Shutdown + +**Symptom:** +Press Ctrl+C, but gateway doesn't stop. + +**Solutions:** +- Press Ctrl+C again for forced exit +- Check for hanging backend connections +- Verify no long-running tool calls +- Use `kill -9` as last resort + +### Debug Logging + +Enable comprehensive debug logging: + +```bash +export GOPHER_LOG_LEVEL=debug +./gateway_server_example +``` + +This shows: +- Backend connection attempts +- Tool discovery and registration +- Request routing decisions +- Backend responses +- Error details + +Example output: +``` +[DEBUG] Gateway: Connecting to server: weather-service +[DEBUG] Gateway: Connected to http://localhost:3001/mcp +[DEBUG] Gateway: Discovered 5 tools from weather-service +[DEBUG] Gateway: Registering tool: get_weather +[DEBUG] Gateway: Registering tool: get_forecast +[DEBUG] Gateway: Gateway initialization complete +[DEBUG] Gateway: Listening on http://0.0.0.0:3003 +[DEBUG] Gateway: Tool call: get_weather +[DEBUG] Gateway: Routing to backend: weather-service +[DEBUG] Gateway: Backend response received +[DEBUG] Gateway: Tool call completed successfully +``` + +### Performance Tuning + +If experiencing performance issues: + +1. **Increase worker threads:** + ```cpp + config.workers = 16; // Default: 4 + ``` + +2. **Increase max sessions:** + ```cpp + config.max_sessions = 500; // Default: 100 + ``` + +3. **Adjust timeouts:** + ```cpp + config.session_timeout = std::chrono::minutes(10); + config.request_timeout = std::chrono::seconds(120); + ``` + +4. **Monitor connections:** + ```bash + watch -n 1 'netstat -an | grep 3003 | wc -l' + ``` + +--- + +## Advanced Topics + +### Custom Tool Routing Logic + +By default, the gateway routes tools based on registration order (last wins for duplicates). For custom routing: + +```cpp +// Create custom composite with routing logic +class CustomComposite : public ServerComposite { + CallToolResult callTool(const std::string& name, ...) override { + // Custom routing logic + if (name.starts_with("premium_")) { + return premium_server_->callTool(name, ...); + } else { + return default_server_->callTool(name, ...); + } + } +}; + +auto composite = std::make_shared(); +auto gateway = GatewayServer::create(composite); +``` + +### Tool Name Namespacing + +Automatically namespace tools by backend: + +```cpp +// When adding servers to composite +composite->addServer(server1, tool_names1, true); // true = add prefix +// Tools become: server1.weather, server1.time, etc. +``` + +### Health Checks + +The gateway provides a health check endpoint: + +```bash +curl http://localhost:3003/health +``` + +Response: +```json +{ + "status": "healthy", + "uptime_seconds": 3600, + "tool_count": 42, + "server_count": 3 +} +``` + +### Metrics and Monitoring + +Integrate with monitoring systems: + +```cpp +// Expose metrics endpoint +gateway->registerMetricsHandler([](auto& ctx) { + nlohmann::json metrics = { + {"tool_calls_total", tool_call_counter}, + {"tool_calls_errors", error_counter}, + {"backend_connection_errors", connection_error_counter}, + {"active_sessions", gateway->getActiveSessionCount()} + }; + ctx.respond(200, metrics.dump()); +}); +``` + +--- + +## See Also + +- [ServerComposite Documentation](ServerComposite.md) - Managing multiple backend servers +- [MCPServer Documentation](MCPServer.md) - Individual MCP server implementation +- [ReActAgent Documentation](ReActAgent.md) - Client agent that uses tools +- [MCP Protocol Specification](https://spec.modelcontextprotocol.io/) - Official MCP protocol docs + +--- + +## Changelog + +### Version 1.0.0 (2026-01-14) + +**Initial Release:** +- Simple API (`create` + `listen`) +- Advanced API (`create` + `start`/`stop`) +- HTTP/SSE and stdio transport support +- Automatic tool discovery and registration +- Automatic reconnection with retry logic +- Graceful shutdown handling +- Comprehensive debug logging +- Thread-safe operations +- Health check endpoint + +--- + +## License + +Copyright © 2026 Gopher Security. All rights reserved. diff --git a/third_party/gopher-orch/docs/JsonToAgentPipeline.md b/third_party/gopher-orch/docs/JsonToAgentPipeline.md new file mode 100644 index 00000000..f39c4e9b --- /dev/null +++ b/third_party/gopher-orch/docs/JsonToAgentPipeline.md @@ -0,0 +1,1556 @@ +# JSON-to-Agent Pipeline: Layered Architecture with ToolRegistry and ServerComposite + +## Overview + +The JSON-to-Agent Pipeline uses a **layered architecture** combining ToolRegistry and ServerComposite to provide a robust, scalable solution for tool management. This approach leverages: + +- **ServerComposite Layer**: Aggregates and manages multiple MCP servers at the infrastructure level +- **ToolRegistry Layer**: Provides agent-friendly tool interface with the composite as a unified backend +- **Clean Separation**: Infrastructure concerns (servers) separated from application concerns (agents) +- **Dynamic Discovery**: Tools discovered at runtime from MCP servers through the composite +- **Unified Execution**: All tool calls routed efficiently through the composite layer + +## Architecture Components + +### High-Level Layered Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Application Layer (Client) │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ LLMProvider │ │ ReActAgent │ │ Business Logic │ │ +│ │ (Anthropic) │ │ │ │ (User's Code) │ │ +│ └─────────────┘ └──────┬───────┘ └─────────────────────────┘ │ +└──────────────────────────┼─────────────────────────────────────────┘ + │ uses + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Agent Interface Layer │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ ToolRegistry │ │ +│ │ • Agent-facing tool repository │ │ +│ │ • Stores tool metadata and specs │ │ +│ │ • Delegates execution to ServerComposite │ │ +│ │ • Manages local tools + server tools │ │ +│ └────────────────────────┬─────────────────────────────────────┘ │ +└───────────────────────────┼─────────────────────────────────────────┘ + │ uses single composite + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ ServerComposite │ │ +│ │ • Aggregates all MCP servers │ │ +│ │ • Namespace management (server1.tool, server2.tool) │ │ +│ │ • Connection pooling and lifecycle │ │ +│ │ • Efficient routing via CompositeServerTool │ │ +│ └────────────────────────┬─────────────────────────────────────┘ │ +│ │ manages & routes │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ MCPServer Instances (Infrastructure) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ MCP 1 │ │ MCP 2 │ │ MCP 3 │ │ MCP N │ │ │ +│ │ │ calc,math│ │ weather │ │ database │ │ email │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ HTTP/SSE connections + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ External MCP Servers │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Calculator │ │ Weather API │ │ Database │ │ +│ │ Server │ │ Server │ │ Server │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Detailed Flow Diagram + +``` +┌──────────────────┐ +│ Configuration │ ← Step 1: Load configuration +│ Source │ (Remote API or Local JSON) +└────────┬─────────┘ + │ Returns JSON config + ▼ +┌──────────────────┐ +│ ToolsFetcher │ ← Step 2: Process configuration +│ .loadFromJson() │ Parse server definitions +└────────┬─────────┘ + │ For each server + ▼ +┌──────────────────┐ +│ MCPServer │ ← Step 3: Create server instances +│ ::create() │ Initialize HTTP/SSE transport +└────────┬─────────┘ + │ Connect & discover + ▼ +┌──────────────────┐ +│ Tool Discovery │ ← Step 4: Discover available tools +│ POST /tools │ Fetch tool definitions from each server +└────────┬─────────┘ + │ Tool definitions + ▼ +┌──────────────────┐ +│ ServerComposite │ ← Step 5: Build composite +│ .addServer() │ Aggregate all servers with namespace management +└────────┬─────────┘ + │ Infrastructure ready + ▼ +┌──────────────────┐ +│ ToolRegistry │ ← Step 6: Create registry with composite backend +│ .addServer() │ Register composite as single unified backend +└────────┬─────────┘ + │ Ready for use + ▼ +┌──────────────────┐ +│ ReActAgent │ ← Step 7: Agent uses tools via registry +│ .run() │ Execute tasks with discovered tools +└────────┬─────────┘ + │ Tool execution request + ▼ +┌──────────────────┐ +│ ToolRegistry │ ← Step 8: Registry delegates to composite +│ .executeTool() │ Routes through ServerComposite +└────────┬─────────┘ + │ Delegates to composite + ▼ +┌──────────────────┐ +│ ServerComposite │ ← Step 9: Composite routes to correct server +│ .callTool() │ Efficient namespace-based routing +└────────┬─────────┘ + │ Routes to specific server + ▼ +┌──────────────────┐ +│ MCPServer │ ← Step 10: Execute on target server +│ HTTP POST │ Actual tool execution via MCP protocol +└──────────────────┘ +``` + +## Configuration Formats + +### Modern Format (Recommended) + +```json +{ + "name": "production-config", + "description": "Production tool configuration", + "mcp_servers": [ + { + "name": "calculator-server", + "transport": "http_sse", + "http_sse": { + "url": "http://calculator.example.com:3001", + "headers": { + "Authorization": "Bearer ${CALC_API_KEY}" + } + }, + "connect_timeout_ms": 10000, + "request_timeout_ms": 30000 + }, + { + "name": "weather-server", + "transport": "http_sse", + "http_sse": { + "url": "http://weather.example.com:3002", + "headers": { + "API-Key": "${WEATHER_API_KEY}" + } + }, + "connect_timeout_ms": 5000, + "request_timeout_ms": 15000 + } + ], + "auth_presets": { + "anthropic": { + "type": "bearer", + "value": "${ANTHROPIC_API_KEY}" + }, + "openai": { + "type": "bearer", + "value": "${OPENAI_API_KEY}" + } + } +} +``` + +## Core Components - Reusing Existing Infrastructure + +The JSON-to-Agent pipeline leverages existing components from gopher-orch, requiring minimal new code: + +### 1. ConfigLoader - Existing Configuration Parser + +The SDK uses the existing `ConfigLoader` from `gopher/orch/agent/config_loader.h`: + +```cpp +namespace gopher::orch::agent { + +// ConfigLoader - Complete JSON configuration parsing +// See: include/gopher/orch/agent/config_loader.h +class ConfigLoader { +public: + // Load configuration from file + Result loadFromFile(const std::string& path); + + // Load configuration from JSON string + Result loadFromString(const std::string& json_string); + + // Environment variable substitution (handles ${VAR_NAME}) + std::string substituteEnvVars(const std::string& input) const; + + // Parse individual components + Result parseToolDefinition(const JsonValue& json); + Result parseMCPServerDefinition(const JsonValue& json); + Result parseAuthPreset(const JsonValue& json); +}; + +// Complete configuration structure +struct RegistryConfig { + std::string name = "tool-registry"; + std::string base_url; + std::map default_headers; + std::map auth_presets; + std::vector mcp_servers; + std::vector tools; +}; + +} // namespace gopher::orch::agent +``` + +### 2. ToolRegistry - Existing Agent Integration + +The SDK uses the existing `ToolRegistry` from `gopher/orch/agent/tool_registry.h`: + +```cpp +namespace gopher::orch::agent { + +// ToolRegistry - Complete tool management for agents +// See: include/gopher/orch/agent/tool_registry.h +class ToolRegistry { +public: + // Load from configuration file + void loadFromFile(const std::string& path, + Dispatcher& dispatcher, + std::function callback); + + // Load from configuration object + void loadConfig(const RegistryConfig& config, + Dispatcher& dispatcher, + std::function callback); + + // Add MCP servers dynamically + void addMCPServer(const MCPServerDefinition& def, + Dispatcher& dispatcher, + std::function callback); + + // Tool access for agents + size_t toolCount() const; + std::vector getToolNames() const; + std::vector getToolSpecs() const; + optional getToolEntry(const std::string& name) const; + + // Execute tool + void executeTool(const std::string& name, + const JsonValue& arguments, + Dispatcher& dispatcher, + JsonCallback callback); + + // Get MCP servers + std::map getMCPServers() const; + MCPServerPtr getMCPServer(const std::string& name) const; +}; + +} // namespace gopher::orch::agent +``` + +### 3. ServerComposite - Existing Server Aggregation + +The SDK uses the existing `ServerComposite` from `gopher/orch/server/server_composite.h`: + +```cpp +namespace gopher::orch::server { + +// ServerComposite - Aggregates multiple servers +// See: include/gopher/orch/server/server_composite.h +class ServerComposite : public Server { +public: + // Create composite with name + static std::shared_ptr create(const std::string& name); + + // Add servers to composite + void addServer(const std::string& name, + std::shared_ptr server, + bool use_prefix = true); + + // Add server with specific tool names + void addServer(const std::string& name, + std::shared_ptr server, + const std::vector& tool_names, + bool use_prefix = true); + + // Server interface implementation + void listTools(Dispatcher& dispatcher, + std::function>)> callback) override; + + void callTool(const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) override; + + // Get all registered servers + std::map> getServers() const; +}; + +} // namespace gopher::orch::server +``` + +### 4. ToolsFetcher - Thin Orchestration Layer + +The ToolsFetcher coordinates the layered architecture: + +```cpp +namespace gopher::orch::sdk { + +// ToolsFetcher - Orchestrates the layered architecture +class ToolsFetcher { +public: + // Load configuration and build layers + void loadFromJson(const std::string& json_config, + Dispatcher& dispatcher, + std::function callback) { + // Step 1: Parse configuration + auto config_result = config_loader_.loadFromString(json_config); + if (!isSuccess(config_result)) { + callback(VoidResult(getError(config_result))); + return; + } + auto config = getValue(config_result); + + // Step 2: Create infrastructure layer (ServerComposite) + composite_ = ServerComposite::create("ToolComposite"); + + // Step 3: Create and connect MCP servers + auto pending = std::make_shared>(config.mcp_servers.size()); + auto servers = std::make_shared>>(); + + for (const auto& server_def : config.mcp_servers) { + MCPServer::create(convertToMCPConfig(server_def), dispatcher, + [=](Result result) { + if (isSuccess(result)) { + auto server = getValue(result); + servers->push_back({server_def.name, server}); + } + + if (--(*pending) == 0) { + // Step 4: Add all servers to composite with tool discovery + for (const auto& [name, server] : *servers) { + server->listTools(dispatcher, + [=](Result> tools_result) { + if (isSuccess(tools_result)) { + auto tools = getValue(tools_result); + std::vector tool_names; + for (const auto& tool : tools) { + tool_names.push_back(tool.name); + } + composite_->addServer(name, server, tool_names, true); + } + }); + } + + // Step 5: Create application layer (ToolRegistry) with composite backend + registry_ = std::make_shared(); + registry_->addServer(composite_, dispatcher); + + callback(VoidResult(nullptr)); + } + }); + } + } + + // Get the layered components + std::shared_ptr getRegistry() const { return registry_; } + std::shared_ptr getComposite() const { return composite_; } + +private: + ConfigLoader config_loader_; + std::shared_ptr composite_; // Infrastructure layer + std::shared_ptr registry_; // Application layer +}; + +} // namespace gopher::orch::sdk +``` + +### 5. MCPServer - Existing MCP Protocol Implementation + +The SDK uses the existing `MCPServer` class from `gopher/orch/server/mcp_server.h` which already supports HTTP+SSE transport: + +```cpp +namespace gopher::orch::server { + +// MCPServer configuration for HTTP+SSE transport +struct MCPServerConfig { + std::string name; // Server name + + // HTTP+SSE transport configuration + struct HttpSseTransport { + std::string url; // Server URL + std::map headers; // HTTP headers + bool verify_ssl = true; // SSL verification + }; + + TransportType transport_type = TransportType::HTTP_SSE; + HttpSseTransport http_sse_transport; + + // Timeouts + std::chrono::milliseconds connect_timeout{30000}; + std::chrono::milliseconds request_timeout{60000}; +}; + +// MCPServer - Full MCP protocol implementation +// See: include/gopher/orch/server/mcp_server.h +class MCPServer : public Server { +public: + // Factory method + static void create( + const MCPServerConfig& config, + Dispatcher& dispatcher, + std::function)> callback, + bool auto_connect = true); + + // Server interface + std::string name() const override; + ConnectionState connectionState() const override; + + void connect(Dispatcher& dispatcher, ConnectionCallback callback) override; + void disconnect(Dispatcher& dispatcher, std::function callback) override; + + void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) override; + + JsonRunnablePtr tool(const std::string& name) override; + + void callTool( + const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) override; +}; + +} // namespace gopher::orch::server +``` + +Key features of the existing MCPServer: +- **Multiple Transports**: Supports stdio, HTTP+SSE, and WebSocket +- **Auto-connection**: Can automatically connect on creation +- **Tool Caching**: Caches tool information and runnables +- **Full MCP Protocol**: Complete implementation of MCP specification +- **Built-in Retries**: Configurable connection retry logic + +### 3. ServerComposite - Multi-Server Manager + +The SDK uses the existing `ServerComposite` class from `gopher/orch/server/server_composite.h`: + +```cpp +namespace gopher::orch::server { + +// ServerComposite aggregates tools from multiple servers +// See: include/gopher/orch/server/server_composite.h +class ServerComposite : public std::enable_shared_from_this { +public: + using Ptr = std::shared_ptr; + + // Factory method + static Ptr create(const std::string& name); + + // Add servers and their tools + ServerComposite& addServer(ServerPtr server, bool namespace_tools = true); + + ServerComposite& addServer( + ServerPtr server, + const std::vector& tool_names, + bool namespace_tools = true); + + ServerComposite& addServerWithAliases( + ServerPtr server, + const std::map& aliases); + + // Get tool by name (supports namespaced and aliased names) + JsonRunnablePtr tool(const std::string& name); + JsonRunnablePtr tool(const std::string& server_name, const std::string& tool_name); + + // List available tools + std::vector listTools() const; + std::vector listToolInfos() const; + + // Connection management + void connectAll( + Dispatcher& dispatcher, + std::function)> callback); + + void disconnectAll( + Dispatcher& dispatcher, + std::function callback); + + // Server management + const std::map& servers() const; + ServerPtr server(const std::string& name) const; + bool hasTool(const std::string& name) const; + void removeServer(const std::string& server_name); +}; + +} // namespace gopher::orch::server +``` + +Key features of the existing ServerComposite: +- **Tool Namespacing**: Tools can be namespaced as `server.tool` or exposed directly +- **Tool Aliasing**: Support for custom tool names via aliases +- **Lazy Connection**: Servers connect when their tools are first used +- **Automatic Routing**: Routes tool calls to the appropriate server +- **Caching**: Caches tool runnables for performance + +### 4. HttpClient - Existing HTTP Communication + +The SDK uses the existing `HttpClient` from `gopher/orch/server/rest_server.h`: + +```cpp +namespace gopher::orch::server { + +// HttpClient - Existing HTTP client implementation +// See: include/gopher/orch/server/rest_server.h +class HttpClient { +public: + virtual void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + ResponseCallback callback) = 0; +}; + +// DefaultHttpClient - Production HTTP client implementation +class DefaultHttpClient : public HttpClient { + // Full HTTP client with connection pooling, SSL, retries +}; + +} // namespace gopher::orch::server +``` + +### 5. Complete Infrastructure Already Available + +The existing gopher-orch codebase provides: + +- **ConfigLoader**: JSON parsing with environment variable substitution ✅ +- **ToolRegistry**: Complete agent-tool integration with MCP server management ✅ +- **MCPServer**: Full MCP protocol implementation with HTTP+SSE ✅ +- **ServerComposite**: Multi-server aggregation and routing ✅ +- **HttpClient**: HTTP communication for remote API calls ✅ +- **Tool Definition Structures**: Complete configuration schemas ✅ +- **Connection Management**: Robust lifecycle handling ✅ + +**Result: 95% of the JSON-to-Agent pipeline already exists!** + +## Tool Discovery Protocol + +### MCP Tool Discovery Request + +```http +POST /tools HTTP/1.1 +Host: localhost:3001 +Content-Type: application/json +Accept: application/json + +{ + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": "discover-001" +} +``` + +### MCP Tool Discovery Response + +```json +{ + "jsonrpc": "2.0", + "result": { + "tools": [ + { + "name": "calculator.add", + "description": "Add two numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"} + }, + "required": ["a", "b"] + } + }, + { + "name": "calculator.multiply", + "description": "Multiply two numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"} + }, + "required": ["a", "b"] + } + }, + { + "name": "weather.get_current", + "description": "Get current weather for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name or coordinates"}, + "units": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "default": "celsius" + } + }, + "required": ["location"] + } + } + ] + }, + "id": "discover-001" +} +``` + +## Tool Execution Flow + +### 1. Agent Requests Tool Execution + +```cpp +// Agent calls ToolRegistry +registry->executeTool( + "calculator.multiply", + JsonValue::object({{"a", 25}, {"b", 4}}), + dispatcher, + [](Result result) { + // Handle result + }); +``` + +### 2. Registry Delegates to ServerComposite + +```cpp +void CompositeToolRegistry::executeTool( + const std::string& name, + const JsonValue& arguments, + Dispatcher& dispatcher, + JsonCallback callback) { + // Delegate to composite + composite_->callTool(name, arguments, dispatcher, callback); +} +``` + +### 3. ServerComposite Routes to Correct Server + +```cpp +// ServerComposite internally resolves tool names and routes to the correct server +// via the CompositeServerTool wrapper: + +class CompositeServerTool : public JsonRunnable { + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Route to the appropriate server + server_->callTool(tool_name_, input, config, dispatcher, + std::move(callback)); + } +}; + +// When executeTool is called, ServerComposite: +// 1. Resolves the tool name to find the server +// 2. Creates or retrieves a cached CompositeServerTool +// 3. The tool wrapper handles routing to the correct server +``` + +### 4. MCP Server Executes Tool + +```http +POST /tool/calculator.multiply HTTP/1.1 +Host: localhost:3001 +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "calculator.multiply", + "arguments": { + "a": 25, + "b": 4 + } + }, + "id": "exec-001" +} +``` + +### 5. Response Flows Back + +```json +{ + "jsonrpc": "2.0", + "result": { + "result": 100, + "metadata": { + "execution_time_ms": 2, + "server": "calculator-server" + } + }, + "id": "exec-001" +} +``` + +## Client Application Usage + +### Basic Integration - Layered Architecture + +```cpp +#include "gopher/orch/sdk/tools_fetcher.h" +#include "gopher/orch/server/server_composite.h" +#include "gopher/orch/server/mcp_server.h" +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/agent/react_agent.h" +#include "gopher/orch/llm/anthropic_provider.h" + +using namespace gopher::orch; + +int main() { + // 1. Create dispatcher + auto dispatcher = std::make_shared("main"); + + // 2. Option A: Use ToolsFetcher for automatic layering + auto fetcher = std::make_shared(); + + bool ready = false; + fetcher->loadFromJson(config_json, *dispatcher, + [&ready](VoidResult result) { + if (isSuccess(result)) { + ready = true; + } + }); + + // Run event loop until ready + while (!ready) { + dispatcher->run(mcp::event::RunType::NonBlock); + } + + // Get the configured layers + auto registry = fetcher->getRegistry(); // Application layer + auto composite = fetcher->getComposite(); // Infrastructure layer + + // 3. Create LLM provider + auto provider = llm::createAnthropicProvider( + getenv("ANTHROPIC_API_KEY"), + "claude-3-opus-20240229"); + + // 5. Create agent with tools + auto agent = agent::ReActAgent::create( + provider, + registry, + agent::AgentConfig() + .withSystemPrompt("You are a helpful assistant with access to tools.") + .withMaxIterations(10)); + + // 6. Run agent with task + agent->run( + "What's the weather in Tokyo and calculate 25 * 4?", + *dispatcher, + [](Result result) { + if (isSuccess(result)) { + auto& r = getSuccess(result); + std::cout << "Response: " << r.response << "\n"; + } + }); + + // 7. Run event loop + dispatcher->run(); + + return 0; +} +``` + +### Option B: Manual Layered Setup + +```cpp +// Manually build the layered architecture for more control + +// 1. Create infrastructure layer (ServerComposite) +auto composite = server::ServerComposite::create("ToolComposite"); + +// 2. Create and add MCP servers +for (const auto& server_config : server_configs) { + server::MCPServer::create(server_config, *dispatcher, + [composite, name = server_config.name](Result result) { + if (isSuccess(result)) { + auto server = getValue(result); + + // Discover tools and add to composite + server->listTools(*dispatcher, + [composite, server, name](Result> tools) { + if (isSuccess(tools)) { + std::vector tool_names; + for (const auto& tool : getValue(tools)) { + tool_names.push_back(tool.name); + } + composite->addServer(name, server, tool_names, true); + } + }); + } + }); +} + +// 3. Create application layer (ToolRegistry) with composite backend +auto registry = agent::ToolRegistry::create(); +registry->addServer(composite, *dispatcher); + +// 4. Use with agent +auto agent = agent::ReActAgent::create(provider, registry, config); +``` + +### Advanced Integration with Remote Config + +```cpp +// Remote configuration with layered architecture +class RemoteConfigLoader { +public: + void loadFromRemote( + const std::string& api_url, + const std::string& api_key, + Dispatcher& dispatcher, + std::function callback) { + + // Fetch configuration from remote API + auto http_client = std::make_shared(); + + http_client->request(HttpMethod::GET, api_url, + {{"Authorization", "Bearer " + api_key}}, "", + dispatcher, + [this, &dispatcher, callback](const HttpResponse& response) { + if (response.status_code != 200) { + callback(VoidResult(Error(-1, "Failed to fetch config"))); + return; + } + + // Use ToolsFetcher to build layered architecture + auto fetcher = std::make_shared(); + fetcher->loadFromJson(response.body, dispatcher, + [this, fetcher, callback](VoidResult result) { + if (isSuccess(result)) { + // Store the layers + this->registry_ = fetcher->getRegistry(); + this->composite_ = fetcher->getComposite(); + callback(VoidResult(nullptr)); + } else { + callback(result); + } + }); + }); + } + + std::shared_ptr getRegistry() const { + return registry_; + } + + std::shared_ptr getComposite() const { + return composite_; + } + +private: + std::shared_ptr registry_; // Application layer + std::shared_ptr composite_; // Infrastructure layer +}; +``` + +### Dynamic Tool Reloading with Layered Architecture + +```cpp +class DynamicToolManager { +public: + void reloadTools(Dispatcher& dispatcher) { + // 1. Disconnect all servers in the composite (infrastructure layer) + if (composite_) { + composite_->disconnectAll(dispatcher, [this, &dispatcher]() { + // 2. Clear the composite + composite_->clear(); + + // 3. Clear the registry (application layer) + registry_->clear(); + + // 4. Reload configuration + this->reloadConfig(dispatcher); + }); + } + } + +private: + void reloadConfig(Dispatcher& dispatcher) { + // Fetch new configuration + auto http_client = std::make_shared(); + + http_client->request(HttpMethod::GET, api_url_, + {{"Authorization", "Bearer " + api_key_}}, "", + dispatcher, + [this, &dispatcher](const HttpResponse& response) { + if (response.status_code == 200) { + // Rebuild the layered architecture + auto fetcher = std::make_shared(); + fetcher->loadFromJson(response.body, dispatcher, + [this, fetcher](VoidResult result) { + if (isSuccess(result)) { + // Update both layers + this->composite_ = fetcher->getComposite(); + this->registry_ = fetcher->getRegistry(); + + // Agent automatically uses updated registry + std::cout << "Reloaded " << registry_->toolCount() + << " tools across " << composite_->servers().size() + << " servers\n"; + } + }); + } + }); + } + + std::shared_ptr composite_; // Infrastructure layer + std::shared_ptr registry_; // Application layer + std::shared_ptr agent_; // Agent using registry + std::string api_url_; + std::string api_key_; +}; +``` + +## SDK vs Client Responsibilities + +### SDK Provides + +1. **Configuration Management** + - Parse JSON configurations + - Support multiple formats + - Handle remote API fetching + +2. **Server Management** + - Create MCP server instances + - Manage connections + - Handle reconnection logic + +3. **Tool Discovery** + - Fetch tool definitions + - Cache tool metadata + - Build tool mappings + +4. **Execution Routing** + - Route tool calls to servers + - Handle response transformation + - Manage timeouts and retries + +5. **Adapter Interface** + - ToolRegistry for agents + - ServerComposite for management + - Unified error handling + +### Client Provides + +1. **LLM Provider** + - Choose provider (Anthropic, OpenAI, etc.) + - Configure API keys + - Set model parameters + +2. **Agent Implementation** + - Create agent instance + - Define system prompts + - Set execution parameters + +3. **Business Logic** + - Task definition + - Result processing + - Error handling + +4. **Event Loop** + - Create dispatcher + - Run event processing + - Handle async operations + +## Performance Optimizations + +### Connection Pooling + +```cpp +class ConnectionPool { + std::map> connections_; + + std::shared_ptr getConnection(const std::string& url) { + auto it = connections_.find(url); + if (it != connections_.end() && it->second->isAlive()) { + return it->second; + } + + auto conn = HttpConnection::create(url); + connections_[url] = conn; + return conn; + } +}; +``` + +### Tool Response Caching + +```cpp +class ToolCache { + struct CacheEntry { + JsonValue result; + std::chrono::steady_clock::time_point expiry; + }; + + std::map cache_; + + std::optional get( + const std::string& tool_name, + const JsonValue& arguments) { + auto key = tool_name + ":" + arguments.toString(); + auto it = cache_.find(key); + + if (it != cache_.end()) { + if (std::chrono::steady_clock::now() < it->second.expiry) { + return it->second.result; + } + cache_.erase(it); + } + + return std::nullopt; + } + + void put( + const std::string& tool_name, + const JsonValue& arguments, + const JsonValue& result, + int ttl_seconds = 60) { + auto key = tool_name + ":" + arguments.toString(); + cache_[key] = { + result, + std::chrono::steady_clock::now() + std::chrono::seconds(ttl_seconds) + }; + } +}; +``` + +### Batch Tool Discovery + +```cpp +void ToolsFetcher::discoverToolsParallel( + Dispatcher& dispatcher, + std::function callback) { + + std::atomic pending{static_cast(servers_.size())}; + std::atomic has_error{false}; + + for (auto& server : servers_) { + server->listTools(dispatcher, + [&pending, &has_error, callback](auto result) { + if (isError(result)) { + has_error = true; + } + + if (--pending == 0) { + if (has_error) { + callback(makeError(ErrorCode::DISCOVERY_FAILED)); + } else { + callback(makeSuccess()); + } + } + }); + } +} +``` + +## Error Handling + +### Connection Failures + +```cpp +// MCPServer already has built-in retry logic via config.max_connect_retries +// Example configuration with retries: +MCPServerConfig config; +config.name = "my-server"; +config.transport_type = MCPServerConfig::TransportType::HTTP_SSE; +config.http_sse_transport.url = "http://localhost:3001"; +config.max_connect_retries = 3; +config.retry_delay = std::chrono::milliseconds(1000); + +MCPServer::create(config, dispatcher, [](Result result) { + if (isSuccess(result)) { + // Server connected with automatic retries + } +}); +``` + +### Tool Execution Failures + +```cpp +void handleToolError(const Error& error) { + switch (error.code) { + case ErrorCode::TOOL_NOT_FOUND: + // Tool doesn't exist - may need to refresh + reloadTools(); + break; + + case ErrorCode::SERVER_UNAVAILABLE: + // Server is down - try fallback + useFallbackServer(); + break; + + case ErrorCode::TIMEOUT: + // Request timed out - retry with longer timeout + retryWithTimeout(error.context); + break; + + case ErrorCode::RATE_LIMITED: + // Rate limited - implement backoff + scheduleRetryWithBackoff(error.context); + break; + + default: + // Log and report + logger.error("Tool execution failed", error); + break; + } +} +``` + +## Security Considerations + +### API Key Management + +```cpp +class SecureConfigLoader { + std::string resolveEnvVariables(const std::string& value) { + // Replace ${VAR_NAME} with environment variable + std::regex env_regex("\\$\\{([^}]+)\\}"); + return std::regex_replace(value, env_regex, + [](const std::smatch& match) { + const char* env_val = std::getenv(match[1].str().c_str()); + return env_val ? std::string(env_val) : match[0].str(); + }); + } + + JsonValue sanitizeConfig(const JsonValue& config) { + // Process headers and auth fields + if (config.hasKey("headers")) { + auto headers = config["headers"]; + for (auto& [key, value] : headers.items()) { + headers[key] = resolveEnvVariables(value.getString()); + } + } + return config; + } +}; +``` + +### Tool Filtering + +```cpp +class ToolFilter { + std::vector allowed_patterns_; + std::vector blocked_patterns_; + + bool isAllowed(const std::string& tool_name) const { + // Check blocked list first + for (const auto& pattern : blocked_patterns_) { + if (std::regex_match(tool_name, pattern)) { + return false; + } + } + + // Check allowed list + if (allowed_patterns_.empty()) { + return true; // Allow all if no filters + } + + for (const auto& pattern : allowed_patterns_) { + if (std::regex_match(tool_name, pattern)) { + return true; + } + } + + return false; + } +}; +``` + +## Testing Strategies + +### Mock MCP Server for Testing + +```cpp +class MockMCPServer : public MCPServer { +public: + MockMCPServer() : MCPServer(createMockConfig()) {} + + void listTools( + Dispatcher& dispatcher, + std::function>)> callback) override { + + std::vector tools = { + {"test.echo", "Echo input", createEchoSchema()}, + {"test.delay", "Delay execution", createDelaySchema()} + }; + + dispatcher.post([callback, tools]() { + callback(makeSuccess(tools)); + }); + } + + void callTool( + const std::string& name, + const JsonValue& arguments, + Dispatcher& dispatcher, + JsonCallback callback) override { + + if (name == "test.echo") { + callback(makeSuccess(arguments)); + } else if (name == "test.delay") { + int delay_ms = arguments["delay_ms"].getInt(); + dispatcher.setTimeout(delay_ms, [callback]() { + callback(makeSuccess(JsonValue("delayed"))); + }); + } else { + callback(makeError( + ErrorCode::TOOL_NOT_FOUND, + "Mock tool not found")); + } + } +}; +``` + +### Integration Test + +```cpp +TEST(JsonToAgentPipeline, FullIntegration) { + auto dispatcher = createTestDispatcher(); + + // Create mock configuration + std::string config_json = R"({ + "mcp_servers": [{ + "name": "test-server", + "transport": "http_sse", + "http_sse": { + "url": "http://localhost:9999" + } + }] + })"; + + // Load tools + auto fetcher = std::make_unique(); + fetcher->loadFromJson(config_json, *dispatcher, [](VoidResult result) { + ASSERT_TRUE(isSuccess(result)); + }); + + // Create registry + auto registry = fetcher->createToolRegistry(); + ASSERT_NE(registry, nullptr); + + // Verify tools are available + auto tool_names = registry->getToolNames(); + ASSERT_FALSE(tool_names.empty()); + + // Execute a tool + bool executed = false; + registry->executeTool( + tool_names[0], + JsonValue::object({{"test", "value"}}), + *dispatcher, + [&executed](Result result) { + executed = true; + ASSERT_TRUE(isSuccess(result)); + }); + + // Run dispatcher until complete + while (!executed) { + dispatcher->runOnce(); + } +} +``` + +## Common Patterns + +### Pattern 1: Multi-Environment Configuration + +```cpp +class EnvironmentConfig { + std::map configs_ = { + {"development", "config/dev-tools.json"}, + {"staging", "config/staging-tools.json"}, + {"production", "config/prod-tools.json"} + }; + + std::string getConfigPath() const { + const char* env = std::getenv("ENVIRONMENT"); + std::string environment = env ? env : "development"; + + auto it = configs_.find(environment); + return it != configs_.end() ? it->second : configs_.at("development"); + } +}; +``` + +### Pattern 2: Tool Capability Discovery + +```cpp +class ToolCapabilities { + struct Capability { + bool supports_batch = false; + bool supports_streaming = false; + int max_concurrent = 1; + int timeout_ms = 30000; + }; + + std::map capabilities_; + + void discoverCapabilities( + const ServerToolInfo& tool_info) { + Capability cap; + + if (tool_info.metadata.hasKey("capabilities")) { + auto& meta = tool_info.metadata["capabilities"]; + cap.supports_batch = meta.getValue("batch", false); + cap.supports_streaming = meta.getValue("streaming", false); + cap.max_concurrent = meta.getValue("max_concurrent", 1); + cap.timeout_ms = meta.getValue("timeout_ms", 30000); + } + + capabilities_[tool_info.name] = cap; + } +}; +``` + +### Pattern 3: Health Monitoring + +```cpp +class ServerHealthMonitor { + struct HealthStatus { + bool is_healthy = true; + int consecutive_failures = 0; + std::chrono::steady_clock::time_point last_check; + std::chrono::steady_clock::time_point last_success; + }; + + std::map health_status_; + + void checkHealth( + MCPServerPtr server, + Dispatcher& dispatcher) { + + server->listTools(dispatcher, + [this, server](Result> result) { + auto& status = health_status_[server->name()]; + + if (isSuccess(result)) { + status.is_healthy = true; + status.consecutive_failures = 0; + status.last_success = std::chrono::steady_clock::now(); + } else { + status.consecutive_failures++; + if (status.consecutive_failures >= 3) { + status.is_healthy = false; + onServerUnhealthy(server); + } + } + + status.last_check = std::chrono::steady_clock::now(); + }); + } + + void onServerUnhealthy(MCPServerPtr server) { + // Notify monitoring system + // Remove from rotation + // Attempt reconnection + } +}; +``` + +## Troubleshooting Guide + +### Common Issues and Solutions + +#### 1. Tools Not Appearing in Registry + +**Problem**: Agent can't see tools after loading configuration + +**Solutions**: +- Verify MCP servers are running and accessible +- Check tool discovery responses for errors +- Ensure createAndConnect() completed successfully +- Verify tool names don't have conflicts + +```cpp +// Debug tool loading +auto composite = fetcher->getComposite(); +auto tools = composite->listToolInfos(); +std::cout << "Discovered " << tools.size() << " tools:\n"; +for (const auto& tool : tools) { + std::cout << " - " << tool.name << ": " << tool.description << "\n"; +} +``` + +#### 2. Tool Execution Timeouts + +**Problem**: Tools timeout during execution + +**Solutions**: +- Increase request_timeout_ms in configuration +- Check network latency to MCP servers +- Verify MCP server processing time +- Implement retry logic for transient failures + +```cpp +// Configure longer timeouts +config["mcp_servers"][0]["request_timeout_ms"] = 60000; // 60 seconds +``` + +#### 3. Connection Failures + +**Problem**: Can't connect to MCP servers + +**Solutions**: +- Verify server URLs are correct +- Check firewall rules +- Ensure authentication headers are set +- Test with curl directly + +```bash +# Test MCP server connectivity +curl -X POST http://localhost:3001/tools \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"test"}' +``` + +#### 4. Memory Leaks + +**Problem**: Memory usage grows over time + +**Solutions**: +- Ensure proper cleanup of server connections +- Clear tool cache periodically +- Use weak_ptr for circular references +- Profile with valgrind or sanitizers + +```cpp +// Periodic cleanup +void cleanupResources() { + // Clear cache + tool_cache_.clear(); + + // Disconnect unused servers + for (auto& server : idle_servers_) { + server->disconnect(dispatcher_, [](){}); + } + idle_servers_.clear(); +} +``` + +## Future Enhancements + +### Planned Features + +1. **WebSocket Support** + - Real-time tool updates + - Bidirectional communication + - Lower latency execution + +2. **GraphQL Integration** + - Query-based tool discovery + - Selective field fetching + - Subscription support + +3. **Tool Versioning** + - Version compatibility checking + - Automatic migration + - Deprecation warnings + +4. **Distributed Tracing** + - OpenTelemetry integration + - Request correlation + - Performance profiling + +5. **Advanced Caching** + - Redis integration + - Distributed cache + - Cache invalidation protocols + +## Conclusion + +The JSON-to-Agent Pipeline implements a **layered architecture** that leverages **95% existing gopher-orch infrastructure**, requiring minimal new code. + +### Layered Architecture Benefits + +#### 1. Clean Separation of Concerns +- **Infrastructure Layer (ServerComposite)**: Manages server connections, pooling, and routing +- **Application Layer (ToolRegistry)**: Provides agent-friendly interface with tool specs +- **Clear Boundaries**: Each layer has distinct responsibilities + +#### 2. Scalability and Performance +- **Single Composite Backend**: All servers managed by one efficient composite +- **Namespace Management**: Tools properly namespaced to avoid conflicts +- **Connection Pooling**: Reused connections across multiple tool calls +- **Caching**: Tool metadata cached at both layers + +#### 3. Flexibility +- **Mix Local and Remote Tools**: ToolRegistry handles both seamlessly +- **Dynamic Server Addition**: Add/remove servers without affecting agents +- **Multiple Discovery Sources**: Support for MCP, REST, and local tools + +### Existing Components Used: +1. **ServerComposite** - Infrastructure layer for multi-server management +2. **ToolRegistry** - Application layer for agent integration +3. **MCPServer** - Complete MCP protocol implementation +4. **ConfigLoader** - JSON parsing with environment variables +5. **HttpClient** - HTTP communication for remote APIs +6. **ToolExecutor** - Execution delegation from registry to servers + +### Minimal New Code Required: +- **ToolsFetcher** - ~100 line orchestration layer that coordinates the architecture + +### Key Implementation Insights: + +1. **ToolRegistry and ServerComposite are Complementary** + - Not competing solutions but layers in a larger architecture + - ToolRegistry uses ServerComposite as its backend for server tools + - Clean delegation pattern maintains separation + +2. **Unified Tool Interface** + - Agents only interact with ToolRegistry + - Registry handles routing to local functions or ServerComposite + - ServerComposite manages the complexity of multiple servers + +3. **Production Ready** + - Inherits robust error handling from existing components + - Built-in connection management and retries + - Monitoring and health checks at each layer + +This layered approach demonstrates that gopher-orch provides a complete, production-ready JSON-to-Agent pipeline. The combination of `ToolRegistry` (application layer) + `ServerComposite` (infrastructure layer) + `MCPServer` (protocol layer) creates a powerful, flexible, and maintainable architecture for tool management. + +## Appendix: Complete Example + +See `examples/sdk/sdk_example.cpp` for a complete working implementation of the JSON-to-Agent pipeline, demonstrating: +- Configuration loading +- Tool discovery +- Agent creation +- Task execution +- Error handling +- Resource cleanup + +## References + +- [MCP Protocol Specification](https://github.com/modelcontextprotocol/specification) +- [Runnable Architecture](./Runnable.md) +- [Agent Framework](./Agent.md) +- [ServerComposite Pattern](./Server.md) +- [SDK Examples](../examples/sdk/) \ No newline at end of file diff --git a/third_party/gopher-orch/docs/LLMProvider.md b/third_party/gopher-orch/docs/LLMProvider.md new file mode 100644 index 00000000..283237a3 --- /dev/null +++ b/third_party/gopher-orch/docs/LLMProvider.md @@ -0,0 +1,331 @@ +# LLMProvider Design Document + +## Overview + +LLMProvider is an abstract interface that provides a unified way to interact with various Large Language Model providers (OpenAI, Anthropic, Ollama, etc.). It handles the complexities of different API formats while exposing a consistent async interface for chat completions with tool support. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Application │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ LLMProvider (Abstract) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ • chat(messages, tools, config, dispatcher, callback) │ │ +│ │ • chatStream(messages, tools, config, ...) │ │ +│ │ • isModelSupported(model) │ │ +│ │ • supportedModels() │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ OpenAIProvider │ │AnthropicProvider│ │ OllamaProvider │ +│ │ │ │ │ │ +│ • GPT-4 │ │ • Claude 3 │ │ • Llama 2 │ +│ • GPT-3.5 │ │ • Claude 3.5 │ │ • Mistral │ +│ • GPT-4o │ │ • Claude Opus │ │ • Custom │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ HttpClient │ +│ (Async HTTP requests via Dispatcher) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Core Components + +### 1. Message Types + +```cpp +enum class Role { + SYSTEM, // System prompt + USER, // User message + ASSISTANT, // Assistant response + TOOL // Tool result +}; + +struct Message { + Role role; + std::string content; + optional tool_call_id; // For TOOL role + optional> tool_calls; // For ASSISTANT with tools +}; +``` + +### 2. Tool Specification + +```cpp +struct ToolSpec { + std::string name; + std::string description; + JsonValue parameters; // JSON Schema +}; + +struct ToolCall { + std::string id; // Unique ID for matching results + std::string name; // Tool name + JsonValue arguments; // Arguments from LLM +}; +``` + +### 3. LLM Configuration + +```cpp +struct LLMConfig { + std::string model; // e.g., "gpt-4", "claude-3-opus" + optional temperature; // 0.0 - 2.0 + optional max_tokens; // Max response tokens + optional top_p; // Nucleus sampling + optional seed; // For reproducibility + std::chrono::milliseconds timeout{60000}; +}; +``` + +## Request Flow + +``` +┌──────────┐ ┌────────────┐ ┌──────────────┐ ┌─────────┐ +│ Client │────▶│ LLMProvider│────▶│ HttpClient │────▶│ LLM API │ +└──────────┘ └────────────┘ └──────────────┘ └─────────┘ + │ │ │ │ + │ chat() │ │ │ + │────────────────▶│ │ │ + │ │ buildRequest() │ │ + │ │──────────────────▶│ │ + │ │ │ HTTP POST │ + │ │ │──────────────────▶│ + │ │ │ │ + │ │ │◀──────────────────│ + │ │ │ JSON Response │ + │ │◀──────────────────│ │ + │ │ parseResponse() │ │ + │◀────────────────│ │ │ + │ callback() │ │ │ + │ LLMResponse │ │ │ +``` + +## Provider-Specific Message Conversion + +### OpenAI Format + +```json +{ + "model": "gpt-4", + "messages": [ + {"role": "system", "content": "..."}, + {"role": "user", "content": "..."}, + {"role": "assistant", "content": "...", "tool_calls": [...]}, + {"role": "tool", "tool_call_id": "...", "content": "..."} + ], + "tools": [...] +} +``` + +### Anthropic Format + +```json +{ + "model": "claude-3-opus-20240229", + "system": "...", + "messages": [ + {"role": "user", "content": "..."}, + {"role": "assistant", "content": [ + {"type": "text", "text": "..."}, + {"type": "tool_use", "id": "...", "name": "...", "input": {...}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "...", "content": "..."} + ]} + ], + "tools": [...] +} +``` + +## Example Usage + +### Basic Chat + +```cpp +#include "gopher/orch/llm/openai_provider.h" + +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +// Create provider +auto provider = OpenAIProvider::create("sk-your-api-key"); + +// Configure request +LLMConfig config("gpt-4"); +config.withTemperature(0.7).withMaxTokens(1000); + +// Build messages +std::vector messages = { + Message::system("You are a helpful assistant."), + Message::user("What is the capital of France?") +}; + +// Make async request +provider->chat(messages, {}, config, dispatcher, + [](Result result) { + if (mcp::holds_alternative(result)) { + auto& response = mcp::get(result); + std::cout << "Response: " << response.message.content << std::endl; + std::cout << "Tokens used: " << response.usage->total_tokens << std::endl; + } else { + auto& error = mcp::get(result); + std::cerr << "Error: " << error.message << std::endl; + } + }); +``` + +### Chat with Tools + +```cpp +// Define tools +std::vector tools; + +JsonValue weatherParams = JsonValue::object(); +weatherParams["type"] = "object"; +JsonValue props = JsonValue::object(); +JsonValue locationProp = JsonValue::object(); +locationProp["type"] = "string"; +locationProp["description"] = "City name"; +props["location"] = locationProp; +weatherParams["properties"] = props; +weatherParams["required"] = JsonValue::array(); +weatherParams["required"].push_back("location"); + +tools.push_back(ToolSpec("get_weather", "Get current weather", weatherParams)); + +// Chat with tools +provider->chat(messages, tools, config, dispatcher, + [](Result result) { + if (mcp::holds_alternative(result)) { + auto& response = mcp::get(result); + + if (response.hasToolCalls()) { + // LLM wants to call tools + for (const auto& call : response.toolCalls()) { + std::cout << "Tool call: " << call.name << std::endl; + std::cout << "Arguments: " << call.arguments.toString() << std::endl; + } + } else { + // Final response + std::cout << "Response: " << response.message.content << std::endl; + } + } + }); +``` + +### Using Anthropic Provider + +```cpp +#include "gopher/orch/llm/anthropic_provider.h" + +// Create with custom configuration +AnthropicConfig config("your-api-key"); +config.withBaseUrl("https://api.anthropic.com") + .withApiVersion("2023-06-01") + .withBeta("tools-2024-04-04"); + +auto provider = AnthropicProvider::create(config); + +// Use same interface as OpenAI +LLMConfig llmConfig("claude-3-5-sonnet-latest"); +provider->chat(messages, tools, llmConfig, dispatcher, callback); +``` + +### Using Factory + +```cpp +#include "gopher/orch/llm/llm_provider.h" + +// Create via factory +ProviderConfig config(ProviderType::OPENAI); +config.withApiKey("sk-...") + .withBaseUrl("https://custom-endpoint.com"); + +auto provider = createProvider(config); + +// Or use convenience functions +auto openai = createOpenAIProvider("sk-..."); +auto anthropic = createAnthropicProvider("ant-..."); +auto ollama = createOllamaProvider("http://localhost:11434"); +``` + +## Error Handling + +```cpp +namespace LLMError { + enum : int { + OK = 0, + INVALID_API_KEY = -100, + RATE_LIMITED = -101, + CONTEXT_LENGTH_EXCEEDED = -102, + INVALID_MODEL = -103, + CONTENT_FILTERED = -104, + SERVICE_UNAVAILABLE = -105, + NETWORK_ERROR = -106, + PARSE_ERROR = -107, + UNKNOWN = -199 + }; +} + +// Handle errors +provider->chat(messages, tools, config, dispatcher, + [](Result result) { + if (!mcp::holds_alternative(result)) { + auto& error = mcp::get(result); + switch (error.code) { + case LLMError::RATE_LIMITED: + // Implement retry with backoff + break; + case LLMError::INVALID_API_KEY: + // Check API key configuration + break; + case LLMError::CONTEXT_LENGTH_EXCEEDED: + // Reduce message history + break; + } + } + }); +``` + +## Thread Safety + +- All public methods must be called from the dispatcher thread +- Callbacks are invoked in the dispatcher thread context +- Provider instances can be shared across multiple calls +- Configuration should be done before making requests + +## Extensibility + +To add a new provider: + +1. Create header `include/gopher/orch/llm/new_provider.h` +2. Implement `LLMProvider` interface +3. Handle provider-specific message/tool format conversion +4. Add factory function to `llm_provider.h` + +```cpp +class NewProvider : public LLMProvider { + public: + std::string name() const override { return "new-provider"; } + + void chat(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) override { + // Implementation + } + + // ... other methods +}; +``` diff --git a/third_party/gopher-orch/docs/MCP-Gateway-Config-Reference.md b/third_party/gopher-orch/docs/MCP-Gateway-Config-Reference.md new file mode 100644 index 00000000..f3e0f7fa --- /dev/null +++ b/third_party/gopher-orch/docs/MCP-Gateway-Config-Reference.md @@ -0,0 +1,275 @@ +# MCP Gateway Configuration Reference + +Quick reference for backend developers on MCP Gateway environment variables and configuration formats. + +--- + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `MCP_GATEWAY_CONFIG_PATH` | Yes | `/etc/mcp/gateway-config.json` | Path to config file (manifest from pod secret) | +| `MCP_GATEWAY_CONFIG_URL` | No | - | API URL to fetch config (fallback mechanism) | +| `MCP_GATEWAY_PORT` | Yes | `3003` | Server listen port | +| `MCP_GATEWAY_HOST` | Yes | `0.0.0.0` | Server listen host | +| `MCP_GATEWAY_NAME` | Yes | `mcp-gateway` | Server name for logging | + +**Example API URL:** +``` +https://api-test.gopher.security/v1/mcp-gateway/{gatewayId}/manifest?accessKeyId={accessKeyId} +``` + +--- + +## Configuration JSON Formats + +### Gateway Manifest (Pod Secret) + +Primary configuration from `MCP_GATEWAY_CONFIG_PATH`: + +```json +{ + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01", + "generatedAt": 1768114552523 + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2, + "retryableCodes": [429, 500, 502, 503, 504] + } + }, + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "gopher-auth-server", + "url": "http://127.0.0.1:3001/mcp" + }, + { + "version": "2025-01-09", + "serverId": "1877234567890123457", + "name": "gopher-auth-server2", + "url": "http://127.0.0.1:3002/mcp" + } + ] +} +``` + +### API Response Format (Fallback) + +Returned by `MCP_GATEWAY_CONFIG_URL` endpoint: + +```json +{ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01", + "generatedAt": 1768114552523 + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2, + "retryableCodes": [429, 500, 502, 503, 504] + } + }, + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "gopher-auth-server", + "url": "http://127.0.0.1:3001/mcp" + }, + { + "version": "2025-01-09", + "serverId": "1877234567890123457", + "name": "gopher-auth-server2", + "url": "http://127.0.0.1:3002/mcp" + } + ] + } +} +``` + +--- + +## Schema Reference + +### Root Manifest Object + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `version` | string | Yes | Manifest schema version (e.g., "2026-01-11") | +| `metadata` | object | Yes | Gateway metadata | +| `config` | object | Yes | Gateway configuration | +| `servers` | array | Yes | List of backend MCP servers | + +### Metadata Object + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `accountId` | string | Yes | Account identifier | +| `gatewayId` | string | Yes | Gateway identifier | +| `gatewayName` | string | Yes | Gateway display name | +| `generatedAt` | number | Yes | Timestamp when manifest was generated (epoch ms) | + +### Config Object + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `connectTimeout` | number | No | 5000 | Connection timeout in milliseconds | +| `requestTimeout` | number | No | 30000 | Request timeout in milliseconds | +| `retryPolicy` | object | No | - | Retry configuration | + +### Retry Policy Object + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `maxAttempts` | number | No | 5 | Maximum retry attempts | +| `initialBackoff` | number | No | 1000 | Initial backoff in milliseconds | +| `backoffMultiplier` | number | No | 2.0 | Backoff multiplier | +| `maxBackoff` | number | No | 30000 | Maximum backoff in milliseconds | +| `jitter` | number | No | 0.2 | Jitter factor (0.0-1.0) | +| `retryableCodes` | array | No | [429,500,502,503,504] | HTTP codes to retry | + +### Server Object + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `serverId` | string | Yes | Unique server identifier | +| `version` | string | Yes | Server configuration version | +| `name` | string | Yes | Server display name | +| `url` | string | Yes | Backend MCP server endpoint URL | + +### API Response Wrapper + +| Field | Type | Description | +|-------|------|-------------| +| `succeeded` | boolean | `true` if request successful | +| `code` | number | Response code (e.g., 200000000) | +| `message` | string | Response message (e.g., "success") | +| `data` | object | Gateway manifest | + +--- + +## API Endpoint + +### Manifest Fetch URL + +``` +GET https://api-test.gopher.security/v1/mcp-gateway/{gatewayId}/manifest?accessKeyId={accessKeyId} +``` + +| Parameter | Location | Description | +|-----------|----------|-------------| +| `gatewayId` | Path | Gateway identifier | +| `accessKeyId` | Query | Access key for authentication | + +### Error Response + +```json +{ + "succeeded": false, + "code": 400000001, + "message": "Gateway not found", + "data": null +} +``` + +--- + +## Configuration Priority + +1. `MCP_GATEWAY_CONFIG_PATH` (pod secret file) - **Primary** +2. `MCP_GATEWAY_CONFIG_URL` (API endpoint) - **Fallback** + +The gateway loads configuration from the file first. If the file doesn't exist or is invalid, it falls back to the API endpoint. + +--- + +## Example Kubernetes Secret + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: mcp-gateway-config + namespace: mcp-system +type: Opaque +stringData: + gateway-config.json: | + { + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01", + "generatedAt": 1768114552523 + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2, + "retryableCodes": [429, 500, 502, 503, 504] + } + }, + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "weather-service", + "url": "http://weather-service.mcp-system.svc.cluster.local:3001/mcp" + }, + { + "version": "2025-01-09", + "serverId": "1877234567890123457", + "name": "auth-service", + "url": "http://auth-service.mcp-system.svc.cluster.local:3002/mcp" + } + ] + } +``` + +--- + +## Example Deployment Environment + +```yaml +env: +- name: MCP_GATEWAY_CONFIG_PATH + value: "/etc/mcp/gateway-config.json" +- name: MCP_GATEWAY_CONFIG_URL + value: "https://api-test.gopher.security/v1/mcp-gateway/694821867856330753/manifest?accessKeyId=AK7HZ9N61Z0B59SYCBD5" +- name: MCP_GATEWAY_PORT + value: "3003" +- name: MCP_GATEWAY_HOST + value: "0.0.0.0" +- name: MCP_GATEWAY_NAME + value: "mcp-gateway" +``` diff --git a/third_party/gopher-orch/docs/MCP-Gateway-Deployment.md b/third_party/gopher-orch/docs/MCP-Gateway-Deployment.md new file mode 100644 index 00000000..6dd8c608 --- /dev/null +++ b/third_party/gopher-orch/docs/MCP-Gateway-Deployment.md @@ -0,0 +1,905 @@ +# MCP Gateway Deployment Guide + +## Overview + +This guide covers deploying the **MCP Gateway Server** as a containerized service in Kubernetes. The gateway aggregates tools from multiple backend MCP servers and exposes them through a single unified endpoint. + +### Deployment Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Kubernetes Cluster │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ MCP Gateway Pod │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────────────────────────┐ │ │ +│ │ │ Secret │ │ mcp-gateway container │ │ │ +│ │ │ (Config) │───▶│ │ │ │ +│ │ │ │ │ - Loads config from env/file/API │ │ │ +│ │ └─────────────────┘ │ - Connects to backend MCP servers │ │ │ +│ │ │ - Exposes unified tool endpoint │ │ │ +│ │ │ - Port 3003 (configurable) │ │ │ +│ │ └─────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ └────────────────────────────────────────┼───────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────────────────────┼───────────────────────────┐ │ +│ │ Service (LoadBalancer/ClusterIP) │ │ +│ │ Port 3003 │ │ +│ └────────────────────────────────────────┼───────────────────────────┘ │ +│ │ │ +└───────────────────────────────────────────┼──────────────────────────────┘ + │ + ▼ + External MCP Clients + (Claude Desktop, ReActAgent, etc.) +``` + +--- + +## Quick Start + +### 1. Build and Push Docker Image + +```bash +# Clone the repository +git clone https://github.com/your-org/gopher-orch.git +cd gopher-orch + +# Build and push to ECR +./docker/build-and-push.sh +``` + +### 2. Create Kubernetes Secret + +```bash +# Create secret with gateway configuration +kubectl create secret generic mcp-gateway-config \ + --from-file=gateway-config.json=/path/to/your/config.json +``` + +### 3. Deploy to Kubernetes + +```bash +kubectl apply -f k8s/mcp-gateway-deployment.yaml +``` + +--- + +## Building the Docker Image + +### Prerequisites + +- Docker with Buildx support +- AWS CLI configured with ECR access +- Git (for submodule initialization) + +### Build Script + +The `docker/build-and-push.sh` script handles the complete build and push workflow: + +```bash +# Default configuration +./docker/build-and-push.sh + +# Custom configuration +AWS_REGION=us-west-2 \ +AWS_ACCOUNT_ID=123456789012 \ +REPOSITORY_NAME=my-mcp-gateway \ +./docker/build-and-push.sh +``` + +### Build Script Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AWS_REGION` | `us-east-1` | AWS region for ECR | +| `AWS_ACCOUNT_ID` | (required) | Your AWS account ID | +| `REPOSITORY_NAME` | `mcp-gateway` | ECR repository name | + +### Manual Build + +```bash +# Build for local testing (current architecture only) +docker build -t mcp-gateway -f docker/Dockerfile . + +# Build multi-arch and push to registry +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t your-registry/mcp-gateway:latest \ + -f docker/Dockerfile \ + --push \ + . +``` + +### Image Tags + +The build script creates the following tags: + +| Tag | Description | +|-----|-------------| +| `latest` | Most recent build | +| `amd64` | AMD64 architecture | +| `arm64` | ARM64 architecture | +| `YYYY.MM.DD-HHMM` | Timestamped version | + +--- + +## Configuration + +The MCP Gateway supports three configuration sources, in priority order: + +1. **Environment Variable** (`MCP_GATEWAY_CONFIG`) - JSON string +2. **Config File** (`MCP_GATEWAY_CONFIG_PATH`) - File path, typically mounted from Kubernetes Secret +3. **API Endpoint** (`MCP_GATEWAY_CONFIG_URL`) - Fetches config from remote API + +### Environment Variables Reference + +#### Required (at least one config source) + +| Variable | Description | +|----------|-------------| +| `MCP_GATEWAY_CONFIG` | JSON configuration string (highest priority) | +| `MCP_GATEWAY_CONFIG_PATH` | Path to config file (default: `/etc/mcp/gateway-config.json`) | +| `MCP_GATEWAY_CONFIG_URL` | API URL to fetch configuration | + +#### Optional + +| Variable | Default | Description | +|----------|---------|-------------| +| `MCP_GATEWAY_ACCESS_KEY` | - | Access key for API authentication | +| `MCP_GATEWAY_PORT` | `3003` | Server listen port | +| `MCP_GATEWAY_HOST` | `0.0.0.0` | Server listen host | +| `MCP_GATEWAY_NAME` | `mcp-gateway` | Server name for logging | + +### Configuration JSON Formats + +The gateway supports two JSON formats depending on the source: + +#### Manifest Format (Config File or Environment Variable) + +Config files use the full manifest format with metadata, config, and servers: + +```json +{ + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01", + "generatedAt": 1768114552523 + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2, + "retryableCodes": [429, 500, 502, 503, 504] + } + }, + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "weather-service", + "url": "http://weather-service.default.svc.cluster.local:3001/mcp" + }, + { + "version": "2025-01-09", + "serverId": "1877234567890123457", + "name": "auth-service", + "url": "http://auth-service.default.svc.cluster.local:3002/mcp" + } + ] +} +``` + +#### API Response Format (MCP_GATEWAY_CONFIG_URL) + +API endpoints return wrapped format with `succeeded` and `data`: + +```json +{ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01", + "generatedAt": 1768114552523 + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2, + "retryableCodes": [429, 500, 502, 503, 504] + } + }, + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "weather-service", + "url": "http://weather-service.mcp-system.svc.cluster.local:3001/mcp" + } + ] + } +} +``` + +### Server Object Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `serverId` | string | Yes | Unique server identifier | +| `version` | string | Yes | Server configuration version | +| `name` | string | Yes | Server display name | +| `url` | string | Yes* | HTTP/SSE endpoint URL | +| `command` | string | Yes* | Stdio command (alternative to url) | +| `args` | array | No | Command arguments (for stdio) | + +*Either `url` (HTTP/SSE) or `command` (stdio) is required. + +### Transport Auto-Detection + +The gateway automatically detects the transport type: +- **HTTP/SSE**: Server has `url` field +- **Stdio**: Server has `command` field + +--- + +## Kubernetes Deployment + +### Complete Deployment Manifest + +Create `k8s/mcp-gateway-deployment.yaml`: + +```yaml +--- +# Namespace (optional) +apiVersion: v1 +kind: Namespace +metadata: + name: mcp-system + +--- +# Secret containing gateway configuration +apiVersion: v1 +kind: Secret +metadata: + name: mcp-gateway-config + namespace: mcp-system +type: Opaque +stringData: + gateway-config.json: | + { + "version": "2026-01-11", + "metadata": { + "accountId": "348716338765762562", + "gatewayId": "694821867856330753", + "gatewayName": "mcp-toolkit-01", + "generatedAt": 1768114552523 + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000, + "retryPolicy": { + "maxAttempts": 5, + "initialBackoff": 1000, + "backoffMultiplier": 2.0, + "maxBackoff": 30000, + "jitter": 0.2, + "retryableCodes": [429, 500, 502, 503, 504] + } + }, + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "weather-service", + "url": "http://weather-service.mcp-system.svc.cluster.local:3001/mcp" + }, + { + "version": "2025-01-09", + "serverId": "1877234567890123457", + "name": "auth-service", + "url": "http://auth-service.mcp-system.svc.cluster.local:3002/mcp" + } + ] + } + +--- +# Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcp-gateway + namespace: mcp-system + labels: + app: mcp-gateway +spec: + replicas: 2 + selector: + matchLabels: + app: mcp-gateway + template: + metadata: + labels: + app: mcp-gateway + spec: + containers: + - name: mcp-gateway + image: 745308818994.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway:latest + ports: + - containerPort: 3003 + name: http + env: + - name: MCP_GATEWAY_PORT + value: "3003" + - name: MCP_GATEWAY_HOST + value: "0.0.0.0" + - name: MCP_GATEWAY_NAME + value: "mcp-gateway" + - name: MCP_GATEWAY_CONFIG_PATH + value: "/etc/mcp/gateway-config.json" + volumeMounts: + - name: config + mountPath: /etc/mcp + readOnly: true + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 3003 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 3003 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: config + secret: + secretName: mcp-gateway-config + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: mcp-gateway + namespace: mcp-system + labels: + app: mcp-gateway +spec: + type: ClusterIP + ports: + - port: 3003 + targetPort: 3003 + protocol: TCP + name: http + selector: + app: mcp-gateway + +--- +# Horizontal Pod Autoscaler (optional) +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: mcp-gateway + namespace: mcp-system +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: mcp-gateway + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 +``` + +### Deploy Commands + +```bash +# Apply all resources +kubectl apply -f k8s/mcp-gateway-deployment.yaml + +# Check deployment status +kubectl -n mcp-system get pods -l app=mcp-gateway + +# View logs +kubectl -n mcp-system logs -l app=mcp-gateway -f + +# Check service +kubectl -n mcp-system get svc mcp-gateway +``` + +--- + +## Configuration Methods + +### Method 1: Kubernetes Secret (Recommended for Production) + +```bash +# Create secret from file +kubectl -n mcp-system create secret generic mcp-gateway-config \ + --from-file=gateway-config.json=./config/gateway-config.json + +# Or create secret from literal JSON +kubectl -n mcp-system create secret generic mcp-gateway-config \ + --from-literal=gateway-config.json='{"succeeded":true,"data":{"servers":[...]}}' +``` + +Mount in deployment: +```yaml +volumes: +- name: config + secret: + secretName: mcp-gateway-config +``` + +### Method 2: Environment Variable (Simple Deployments) + +```yaml +env: +- name: MCP_GATEWAY_CONFIG + value: '{"version":"2026-01-11","metadata":{"gatewayId":"123"},"config":{"connectTimeout":5000,"requestTimeout":30000},"servers":[{"serverId":"1","name":"server1","url":"http://localhost:3001/mcp"}]}' +``` + +### Method 3: ConfigMap (Non-Sensitive Configuration) + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-gateway-config + namespace: mcp-system +data: + gateway-config.json: | + { + "version": "2026-01-11", + "metadata": { + "gatewayId": "694821867856330753", + "gatewayName": "mcp-gateway-dev" + }, + "config": { + "connectTimeout": 5000, + "requestTimeout": 30000 + }, + "servers": [ + { + "serverId": "1877234567890123456", + "name": "public-service", + "url": "http://public-service:3001/mcp" + } + ] + } +``` + +### Method 4: API Endpoint (Dynamic Configuration) + +```yaml +env: +- name: MCP_GATEWAY_CONFIG_URL + value: "https://api.example.com/v1/mcp-servers/12345/manifest" +- name: MCP_GATEWAY_ACCESS_KEY + valueFrom: + secretKeyRef: + name: api-credentials + key: access-key +``` + +--- + +## Health Checks and Monitoring + +### Health Endpoint + +The gateway exposes a health check endpoint at `/health`: + +```bash +# Check health status +curl http://mcp-gateway:3003/health +``` + +Response: +```json +{ + "status": "healthy", + "uptime_seconds": 3600, + "tool_count": 42, + "server_count": 3 +} +``` + +### Kubernetes Probes + +```yaml +livenessProbe: + httpGet: + path: /health + port: 3003 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /health + port: 3003 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +``` + +### Monitoring with Prometheus + +```yaml +# ServiceMonitor for Prometheus Operator +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: mcp-gateway + namespace: mcp-system +spec: + selector: + matchLabels: + app: mcp-gateway + endpoints: + - port: http + path: /metrics + interval: 30s +``` + +### Logging + +View gateway logs: + +```bash +# Follow logs +kubectl -n mcp-system logs -l app=mcp-gateway -f + +# Get logs from specific pod +kubectl -n mcp-system logs mcp-gateway-xxxxxx-xxxxx + +# Get logs with timestamps +kubectl -n mcp-system logs -l app=mcp-gateway --timestamps +``` + +Log output format: +``` +[Gateway] MCP Gateway Server starting... +[Gateway] Loading config from /etc/mcp/gateway-config.json +[Gateway] Creating gateway server... +[Gateway] Gateway created successfully +[Gateway] Tools: 15 +[Gateway] Servers: 3 +[Gateway] Listening on 0.0.0.0:3003 +``` + +--- + +## Production Best Practices + +### 1. Resource Limits + +Always set resource requests and limits: + +```yaml +resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" +``` + +### 2. Security Context + +Run as non-root user: + +```yaml +securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + readOnlyRootFilesystem: true +``` + +### 3. Network Policies + +Restrict network access: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: mcp-gateway + namespace: mcp-system +spec: + podSelector: + matchLabels: + app: mcp-gateway + policyTypes: + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + name: client-namespace + ports: + - port: 3003 + egress: + - to: + - namespaceSelector: + matchLabels: + name: mcp-system + ports: + - port: 3001 + - port: 3002 +``` + +### 4. Pod Disruption Budget + +Ensure availability during updates: + +```yaml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: mcp-gateway + namespace: mcp-system +spec: + minAvailable: 1 + selector: + matchLabels: + app: mcp-gateway +``` + +### 5. Rolling Updates + +Configure update strategy: + +```yaml +spec: + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 +``` + +### 6. Anti-Affinity + +Spread pods across nodes: + +```yaml +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: mcp-gateway + topologyKey: kubernetes.io/hostname +``` + +--- + +## Updating the Gateway + +### Rolling Update with New Image + +```bash +# Push new image +./docker/build-and-push.sh + +# Update deployment to use new image +kubectl -n mcp-system set image deployment/mcp-gateway \ + mcp-gateway=745308818994.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway:2024.01.15-1430 + +# Or use latest and restart pods +kubectl -n mcp-system rollout restart deployment/mcp-gateway + +# Watch rollout status +kubectl -n mcp-system rollout status deployment/mcp-gateway +``` + +### Updating Configuration + +```bash +# Update secret +kubectl -n mcp-system create secret generic mcp-gateway-config \ + --from-file=gateway-config.json=./config/new-config.json \ + --dry-run=client -o yaml | kubectl apply -f - + +# Restart pods to pick up new config +kubectl -n mcp-system rollout restart deployment/mcp-gateway +``` + +### Rollback + +```bash +# View rollout history +kubectl -n mcp-system rollout history deployment/mcp-gateway + +# Rollback to previous version +kubectl -n mcp-system rollout undo deployment/mcp-gateway + +# Rollback to specific revision +kubectl -n mcp-system rollout undo deployment/mcp-gateway --to-revision=2 +``` + +--- + +## Troubleshooting + +### Gateway Won't Start + +**Symptom:** Pod in CrashLoopBackOff + +**Check logs:** +```bash +kubectl -n mcp-system logs mcp-gateway-xxxxx +``` + +**Common causes:** +1. Missing configuration - Ensure secret is mounted +2. Invalid JSON - Validate configuration format +3. Permission denied - Check security context + +### No Tools Registered + +**Symptom:** `toolCount() == 0` + +**Solutions:** +1. Check backend server URLs are accessible from gateway pod +2. Verify backend servers are running +3. Check network policies allow egress to backends +4. Enable debug logging: + ```yaml + env: + - name: GOPHER_LOG_LEVEL + value: "debug" + ``` + +### Connection Refused to Backends + +**Symptom:** Gateway can't reach backend services + +**Debug:** +```bash +# Exec into pod +kubectl -n mcp-system exec -it mcp-gateway-xxxxx -- /bin/sh + +# Test connectivity (if curl available in image) +curl -v http://weather-service:3001/mcp +``` + +**Solutions:** +1. Verify service DNS names are correct +2. Check services are running: `kubectl get svc` +3. Verify network policies + +### Health Check Failing + +**Symptom:** Pod not becoming Ready + +**Check:** +```bash +# Test health endpoint manually +kubectl -n mcp-system port-forward svc/mcp-gateway 3003:3003 +curl localhost:3003/health +``` + +**Solutions:** +1. Increase `initialDelaySeconds` if gateway takes time to start +2. Check gateway logs for startup errors +3. Verify port configuration matches probe port + +--- + +## Local Testing + +### Run Locally with Docker + +```bash +# Build image +docker build -t mcp-gateway -f docker/Dockerfile . + +# Run with environment variable config +docker run -p 3003:3003 \ + -e MCP_GATEWAY_CONFIG='{"version":"2026-01-11","metadata":{"gatewayId":"123"},"config":{},"servers":[{"serverId":"1","name":"test","url":"http://host.docker.internal:3001/mcp"}]}' \ + mcp-gateway + +# Run with mounted config file +docker run -p 3003:3003 \ + -v $(pwd)/config:/etc/mcp:ro \ + mcp-gateway +``` + +### Test Health Endpoint + +```bash +curl http://localhost:3003/health +``` + +### Test Tool Listing + +```bash +# Using curl to test MCP endpoint +curl -X POST http://localhost:3003/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +--- + +## See Also + +- [GatewayServer API Documentation](GatewayServer.md) - Detailed API reference +- [MCP Protocol Specification](https://spec.modelcontextprotocol.io/) - Official MCP protocol docs +- [Docker Documentation](../docker/README.md) - Docker build details + +--- + +## Changelog + +### Version 1.0.0 (2026-01-14) + +**Initial Release:** +- Multi-stage Docker build (Ubuntu 22.04) +- Multi-architecture support (amd64, arm64) +- Three configuration methods (env, file, API) +- Production-ready Kubernetes manifests +- Health check endpoint +- Non-root container execution +- Comprehensive deployment documentation diff --git a/third_party/gopher-orch/docs/Resilience.md b/third_party/gopher-orch/docs/Resilience.md new file mode 100644 index 00000000..385076de --- /dev/null +++ b/third_party/gopher-orch/docs/Resilience.md @@ -0,0 +1,323 @@ +# Resilience Patterns + +Gopher Orch provides four production-grade resilience patterns: **Retry**, **Timeout**, **Fallback**, and **Circuit Breaker**. These patterns wrap any Runnable to add reliability. + +## Overview + +| Pattern | Purpose | Use Case | +|---------|---------|----------| +| Retry | Repeat on failure | Transient errors, network issues | +| Timeout | Limit execution time | Prevent hanging operations | +| Fallback | Try alternatives | Graceful degradation | +| Circuit Breaker | Prevent cascade failures | Failing external services | + +## Retry + +Automatically retry failed operations with exponential backoff. + +### Basic Usage + +```cpp +#include "gopher/orch/resilience/retry.h" + +using namespace gopher::orch::resilience; + +// Default: 3 attempts, exponential backoff +auto reliable = withRetry(unreliableOperation); + +// Custom policy +auto custom = withRetry(operation, RetryPolicy() + .max_attempts(5) + .initial_delay_ms(100) + .backoff_multiplier(2.0) + .max_delay_ms(10000) + .jitter(true)); +``` + +### RetryPolicy Options + +```cpp +struct RetryPolicy { + uint32_t max_attempts = 3; // Total attempts (including first) + uint64_t initial_delay_ms = 500; // Delay before first retry + double backoff_multiplier = 2.0; // Multiply delay each retry + uint64_t max_delay_ms = 30000; // Cap on delay + bool jitter = true; // Add random jitter (±50%) + + // Optional: only retry specific errors + std::function retry_on; + + // Optional: callback on each retry (for logging) + std::function on_retry; +}; +``` + +### Factory Methods + +```cpp +// Exponential backoff (default) +auto policy = RetryPolicy::exponential(3, 500); + +// Fixed delay (no backoff) +auto policy = RetryPolicy::fixed(5, 1000); +``` + +### Selective Retry + +Only retry specific errors: + +```cpp +auto policy = RetryPolicy(); +policy.retry_on = [](const Error& e) { + // Only retry network errors + return e.code == NetworkError::TIMEOUT || + e.code == NetworkError::CONNECTION_RESET; +}; + +auto reliable = withRetry(operation, policy); +``` + +## Timeout + +Limit execution time for any operation. + +### Basic Usage + +```cpp +#include "gopher/orch/resilience/timeout.h" + +using namespace gopher::orch::resilience; + +// 30 second timeout +auto bounded = withTimeout(slowOperation, 30000); + +// Invoke - returns TIMEOUT error if exceeded +bounded->invoke(input, config, dispatcher, [](Result result) { + if (mcp::holds_alternative(result)) { + auto& error = mcp::get(result); + if (error.code == OrchError::TIMEOUT) { + std::cout << "Operation timed out!" << std::endl; + } + } +}); +``` + +### Nested Timeouts + +Inner timeouts take precedence: + +```cpp +// Outer: 60 seconds +auto outer = withTimeout( + // Inner: 10 seconds (triggers first) + withTimeout(slowOp, 10000), + 60000 +); +``` + +## Fallback + +Try alternative operations on failure. + +### Basic Usage + +```cpp +#include "gopher/orch/resilience/fallback.h" + +using namespace gopher::orch::resilience; + +// Try primary, then fallback +auto safe = withFallback(primaryApi) + .orElse(backupApi) + .orElse(cachedResponse) + .build(); +``` + +### Multiple Fallbacks + +```cpp +auto robust = withFallback(premiumService) + .orElse(standardService) + .orElse(freeService) + .orElse(offlineCache) + .build(); + +// Tries each in order until one succeeds +// Returns FALLBACK_EXHAUSTED if all fail +``` + +### With Different Strategies + +```cpp +// Fast path with slow fallback +auto tiered = withFallback( + withTimeout(fastCache, 100)) // 100ms timeout for cache + .orElse(database) // Fall back to DB + .build(); +``` + +## Circuit Breaker + +Prevent cascade failures by stopping calls to failing services. + +### Basic Usage + +```cpp +#include "gopher/orch/resilience/circuit_breaker.h" + +using namespace gopher::orch::resilience; + +// Default: 5 failures, 30s recovery +auto protected = withCircuitBreaker(externalService); + +// Custom policy +auto custom = withCircuitBreaker(service, CircuitBreakerPolicy() + .failure_threshold(3) + .recovery_timeout_ms(10000) + .half_open_max_calls(2)); +``` + +### Circuit States + +``` + ┌─────────────────────────────────────────┐ + │ │ + │ CLOSED ──(failures >= threshold)──> OPEN + │ │ │ + │ │ │ + │ (success) (recovery timeout) + │ │ │ + │ │ ▼ + │ └─────────── HALF_OPEN <────────────┘ + │ │ + │ (success/failure) + │ │ + └─────────────────────┘ +``` + +- **CLOSED**: Normal operation, requests pass through +- **OPEN**: Failures exceeded threshold, requests immediately rejected +- **HALF_OPEN**: Testing recovery, limited requests allowed + +### CircuitBreakerPolicy Options + +```cpp +struct CircuitBreakerPolicy { + uint32_t failure_threshold = 5; // Failures to open circuit + uint64_t recovery_timeout_ms = 30000; // Time before half-open + uint32_t half_open_max_calls = 3; // Successes to close circuit + + // Optional: callback on state changes + std::function on_state_change; +}; +``` + +### Monitoring State + +```cpp +auto cb = withCircuitBreaker(service, policy); + +// Check state +CircuitState state = cb->state(); +uint32_t failures = cb->failureCount(); + +// Manual reset (for testing/admin) +cb->reset(); +``` + +### Factory Methods + +```cpp +// Standard policy +auto policy = CircuitBreakerPolicy::standard(); + +// Aggressive (quick to open) +auto policy = CircuitBreakerPolicy::aggressive(3, 10000); + +// Lenient (slow to open) +auto policy = CircuitBreakerPolicy::lenient(10, 60000); +``` + +## Combining Patterns + +Patterns can be stacked for comprehensive reliability: + +```cpp +// Full resilience stack +auto robust = withCircuitBreaker( + withFallback( + withRetry( + withTimeout(externalApi, 5000), // 5s timeout + RetryPolicy::exponential(3) // 3 retries + ) + ) + .orElse(cachedResponse) // Fallback to cache + .build(), + CircuitBreakerPolicy::aggressive() // Fast circuit breaker +); +``` + +### Recommended Order + +From inner to outer: +1. **Timeout** - Limit individual attempt time +2. **Retry** - Retry failed attempts +3. **Fallback** - Try alternatives if all retries fail +4. **Circuit Breaker** - Prevent calling failing services + +```cpp +auto stack = + withCircuitBreaker( // 4. Outer: circuit breaker + withFallback( // 3. Try alternatives + withRetry( // 2. Retry on failure + withTimeout( // 1. Inner: timeout each attempt + operation, + 1000), + RetryPolicy::exponential(3))) + .orElse(fallback) + .build()); +``` + +## Observability + +All patterns support callbacks for monitoring: + +```cpp +// Retry logging +RetryPolicy policy; +policy.on_retry = [](const Error& e, uint32_t attempt) { + LOG(INFO) << "Retry attempt " << attempt << ": " << e.message; +}; + +// Circuit breaker state changes +CircuitBreakerPolicy cbPolicy; +cbPolicy.on_state_change = [](CircuitState from, CircuitState to) { + LOG(WARNING) << "Circuit breaker: " << toString(from) + << " -> " << toString(to); +}; +``` + +## Best Practices + +1. **Set appropriate timeouts** - Don't let operations hang indefinitely +2. **Use jitter in retries** - Prevent thundering herd +3. **Configure circuit breakers per service** - Different services need different thresholds +4. **Monitor circuit state** - Alert when circuits open +5. **Test failure scenarios** - Verify resilience works as expected +6. **Have meaningful fallbacks** - Cached data is better than errors + +## Error Codes + +```cpp +namespace OrchError { + TIMEOUT = -100, // Operation timed out + CIRCUIT_OPEN = -101, // Circuit breaker is open + FALLBACK_EXHAUSTED = -102 // All fallback options failed +} +``` + +## See Also + +- [Runnable Interface](Runnable.md) - Core interface +- [Composition Patterns](Composition.md) - Sequence, Parallel, Router +- [Server Abstraction](Server.md) - Building reliable services diff --git a/third_party/gopher-orch/docs/Runnable.md b/third_party/gopher-orch/docs/Runnable.md new file mode 100644 index 00000000..f5e12f4a --- /dev/null +++ b/third_party/gopher-orch/docs/Runnable.md @@ -0,0 +1,222 @@ +# Runnable Interface + +The `Runnable` interface is the universal building block for all composable operations in Gopher Orch. Every operation - from simple lambdas to complex AI agents - implements this interface. + +## Overview + +```cpp +template +class Runnable { +public: + virtual std::string name() const = 0; + virtual void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) = 0; +}; +``` + +## Design Principles + +### 1. Async-First + +All operations use callbacks - there are no blocking calls. This enables: +- Non-blocking I/O for network operations +- Efficient use of event loops +- Natural integration with the dispatcher model + +### 2. Dispatcher-Native + +Callbacks are always invoked in dispatcher thread context: +- Thread-safe by design +- No need for locks in most code +- Predictable execution order + +### 3. Type-Safe + +Strong typing with explicit Input/Output types: +- Compile-time type checking +- Clear interfaces between components +- No runtime type errors + +### 4. Composable + +Runnables can be combined using composition patterns: +- `Sequence`: Chain operations (A | B | C) +- `Parallel`: Execute concurrently +- `Router`: Conditional branching +- Resilience wrappers: Retry, Timeout, Fallback, CircuitBreaker + +## Quick Start + +### Creating a Lambda Runnable + +```cpp +#include "gopher/orch/core/lambda.h" + +using namespace gopher::orch::core; + +// Synchronous lambda (simplest form) +auto greet = makeSyncLambda( + [](const std::string& name) -> Result { + return makeSuccess("Hello, " + name + "!"); + }); + +// Async lambda with dispatcher +auto fetch = makeLambda( + [](const std::string& url, Dispatcher& d, ResultCallback cb) { + // Perform async HTTP request... + d.post([cb = std::move(cb)]() { + cb(makeSuccess(JsonValue::object())); + }); + }); +``` + +### Invoking a Runnable + +```cpp +// Get dispatcher (from event loop) +Dispatcher& dispatcher = getDispatcher(); + +// Invoke with callback +greet->invoke("World", RunnableConfig(), dispatcher, + [](Result result) { + if (mcp::holds_alternative(result)) { + std::cout << mcp::get(result) << std::endl; + } else { + std::cerr << mcp::get(result).message << std::endl; + } + }); + +// Run event loop +dispatcher.run(); +``` + +## JsonRunnable + +For dynamic, type-erased operations, use `JsonRunnable`: + +```cpp +using JsonRunnable = Runnable; +using JsonRunnablePtr = std::shared_ptr; +``` + +This is used by: +- Composition patterns (Sequence, Parallel, Router) +- StateGraph nodes +- FFI bindings + +## RunnableConfig + +Configuration passed to every invocation: + +```cpp +struct RunnableConfig { + std::map tags; // Tracing tags + std::map metadata; // Custom metadata + optional timeout; // Operation timeout + + // Create child config for nested operations + RunnableConfig child() const; +}; +``` + +## Implementing Custom Runnables + +### Basic Implementation + +```cpp +class MyRunnable : public Runnable { +public: + std::string name() const override { + return "MyRunnable"; + } + + void invoke(const std::string& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Perform operation... + int result = input.length(); + + // Always post callback to dispatcher + dispatcher.post([callback = std::move(callback), result]() { + callback(makeSuccess(result)); + }); + } +}; +``` + +### Rules for Implementations + +1. **Call callback exactly once** - Either success or error, never both, never zero times +2. **Post to dispatcher** - If not already in dispatcher context, use `dispatcher.post()` +3. **Handle errors gracefully** - Catch exceptions and convert to Error results +4. **Use shared_from_this()** - For capturing `this` in async callbacks + +## Helper Methods + +The base class provides helper methods: + +```cpp +// Post result to dispatcher +template +static void postResult(Dispatcher& dispatcher, + ResultCallback callback, + Result result); + +// Post error to dispatcher +template +static void postError(Dispatcher& dispatcher, + ResultCallback callback, + int code, + const std::string& message); +``` + +## Composition + +Runnables are designed to be composed: + +```cpp +// Chain with pipe operator +auto pipeline = step1 | step2 | step3; + +// Or use builders +auto seq = sequence() + .add(step1) + .add(step2) + .add(step3) + .build(); + +// Add resilience +auto reliable = withRetry(pipeline, RetryPolicy::exponential(3)); +auto bounded = withTimeout(reliable, 30000); // 30 seconds +``` + +## Type Aliases + +Common type aliases for convenience: + +```cpp +// JSON-based runnables +using JsonRunnable = Runnable; +using JsonRunnablePtr = std::shared_ptr; + +// Result callbacks +template +using ResultCallback = std::function)>; +``` + +## Best Practices + +1. **Prefer composition over inheritance** - Use lambdas and composition patterns +2. **Keep runnables focused** - Single responsibility principle +3. **Use descriptive names** - The `name()` method helps debugging +4. **Handle all errors** - Never let exceptions escape +5. **Test with MockServer** - Use mocks for unit testing + +## See Also + +- [Composition Patterns](Composition.md) - Sequence, Parallel, Router +- [Resilience Patterns](Resilience.md) - Retry, Timeout, Fallback, CircuitBreaker +- [Agent Framework](Agent.md) - Building AI agents with tools diff --git a/third_party/gopher-orch/docs/Server.md b/third_party/gopher-orch/docs/Server.md new file mode 100644 index 00000000..84693e24 --- /dev/null +++ b/third_party/gopher-orch/docs/Server.md @@ -0,0 +1,301 @@ +# Server Abstraction + +Gopher Orch provides a protocol-agnostic server abstraction. Register tools once, expose via MCP, REST, or Mock protocols interchangeably. + +## Overview + +``` +┌─────────────────────────────────────────┐ +│ Tool Registry │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Tool1│ │Tool2│ │Tool3│ │Tool4│ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ │ +└───────────────────┬─────────────────────┘ + │ + ┌─────────┴─────────┐ + │ Server Interface │ + └─────────┬─────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────┐ ┌───────────┐ ┌───────────┐ +│ MCP │ │ REST │ │ Mock │ +│ Server │ │ Server │ │ Server │ +└─────────┘ └───────────┘ └───────────┘ +``` + +## Tool Registry + +Register tools that can be exposed via any protocol: + +```cpp +#include "gopher/orch/agent/tool_registry.h" + +using namespace gopher::orch::agent; + +auto registry = makeToolRegistry(); + +// Synchronous tool +registry->addSyncTool( + "calculator", + "Perform mathematical calculations", + JsonValue::object({{"expression", "string"}}), + [](const JsonValue& args) -> Result { + auto expr = args["expression"].getString(); + double result = evaluate(expr); + return makeSuccess(JsonValue(result)); + }); + +// Async tool +registry->addTool( + "search", + "Search the web", + JsonValue::object({{"query", "string"}}), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + auto query = args["query"].getString(); + searchWeb(query, d, [cb = std::move(cb)](Result result) { + cb(std::move(result)); + }); + }); +``` + +## MCP Server + +Expose tools via Model Context Protocol: + +```cpp +#include "gopher/orch/server/mcp_server.h" + +using namespace gopher::orch::server; + +// Create MCP server with registry +MCPServerConfig config; +config.name = "my-agent-server"; +config.version = "1.0.0"; + +auto mcpServer = makeMCPServer(registry, config); + +// Listen on TCP +mcpServer->listen("tcp://0.0.0.0:8080"); + +// Or stdio for CLI tools +mcpServer->listen("stdio://"); + +// Run event loop +mcpServer->run(); +``` + +### MCP Server Configuration + +```cpp +struct MCPServerConfig { + std::string name; // Server name + std::string version; // Server version + std::string description; // Human-readable description + + // Capabilities + bool supports_sampling = false; + bool supports_resources = true; + bool supports_prompts = true; + + // Timeouts + uint64_t request_timeout_ms = 30000; + uint64_t session_timeout_ms = 300000; + + // Worker threads + int worker_threads = 4; +}; +``` + +## REST Server + +Expose tools via REST API: + +```cpp +#include "gopher/orch/server/rest_server.h" + +using namespace gopher::orch::server; + +RESTServerConfig config; +config.port = 3000; +config.host = "0.0.0.0"; + +auto restServer = makeRESTServer(registry, config); + +// Tools are exposed as POST endpoints: +// POST /tools/calculator +// POST /tools/search + +restServer->listen(); +restServer->run(); +``` + +### REST API Format + +**Request:** +```http +POST /tools/calculator +Content-Type: application/json + +{ + "expression": "2 + 2" +} +``` + +**Response:** +```json +{ + "success": true, + "result": 4 +} +``` + +**Error Response:** +```json +{ + "success": false, + "error": { + "code": -1, + "message": "Invalid expression" + } +} +``` + +## Mock Server + +For unit testing without network: + +```cpp +#include "gopher/orch/server/mock_server.h" + +using namespace gopher::orch::server; + +auto mockServer = makeMockServer(registry); + +// Set mock responses +mockServer->setToolResponse("search", JsonValue::object({ + {"results", JsonValue::array({...})} +})); + +// Or set errors +mockServer->setToolError("calculator", -1, "Mock error"); + +// Use in tests +auto agent = makeAgent(mockServer); +``` + +### Testing with MockServer + +```cpp +TEST(AgentTest, UsesSearchTool) { + auto registry = makeToolRegistry(); + // ... register tools ... + + auto mockServer = makeMockServer(registry); + mockServer->setToolResponse("search", mockResults); + + auto agent = makeAgent(mockServer); + + auto result = runToCompletion([&](Dispatcher& d, Callback cb) { + agent->invoke("Search for weather", config, d, std::move(cb)); + }); + + EXPECT_TRUE(result["success"].getBool()); + EXPECT_EQ(mockServer->callCount("search"), 1); +} +``` + +## Server Interface + +All servers implement a common interface: + +```cpp +class Server { +public: + virtual ~Server() = default; + + // Get tool specifications + virtual std::vector getTools() const = 0; + + // Execute a tool + virtual void callTool(const std::string& name, + const JsonValue& args, + Dispatcher& dispatcher, + JsonCallback callback) = 0; + + // List available tools + virtual JsonValue listTools() const = 0; +}; +``` + +## Composite Server + +Combine multiple tool sources: + +```cpp +#include "gopher/orch/server/composite_server.h" + +auto composite = makeCompositeServer(); + +// Add local tools +composite->addRegistry(localRegistry); + +// Add remote MCP servers +composite->addMCPClient("tcp://tools-server:8080"); +composite->addMCPClient("tcp://ai-server:8080"); + +// All tools are unified +auto tools = composite->listTools(); +// Returns tools from all sources +``` + +## Tool Approval + +Add human-in-the-loop for sensitive tools: + +```cpp +#include "gopher/orch/human/human_approval.h" + +auto approver = makeHumanApproval(); + +// Require approval for specific tools +approver->requireApproval("delete_file"); +approver->requireApproval("send_email"); + +// Set approval handler +approver->setHandler([](const ToolCall& call) -> bool { + std::cout << "Approve " << call.name << "? (y/n): "; + char response; + std::cin >> response; + return response == 'y'; +}); + +// Wrap server with approval +auto protected = withApproval(server, approver); +``` + +## Best Practices + +1. **Use MockServer for tests** - No network dependencies in unit tests +2. **Define schemas** - Validate tool arguments +3. **Handle errors gracefully** - Return meaningful error messages +4. **Set timeouts** - Prevent hanging tool calls +5. **Log tool usage** - For debugging and auditing +6. **Version your API** - Include version in server config + +## Protocol Comparison + +| Feature | MCP | REST | Mock | +|---------|-----|------|------| +| Streaming | Yes (SSE) | No | N/A | +| Bi-directional | Yes | No | N/A | +| Discovery | Built-in | Custom | N/A | +| Authentication | Protocol-level | HTTP-based | N/A | +| Best for | AI agents | Web services | Testing | + +## See Also + +- [Tool Registry](ToolRegistry.md) - Detailed tool registration guide +- [Agent Framework](Agent.md) - Using servers with agents +- [FFI Guide](FFI.md) - Cross-language server integration diff --git a/third_party/gopher-orch/docs/StateGraph.md b/third_party/gopher-orch/docs/StateGraph.md new file mode 100644 index 00000000..f4c1d14e --- /dev/null +++ b/third_party/gopher-orch/docs/StateGraph.md @@ -0,0 +1,305 @@ +# StateGraph Guide + +StateGraph provides LangGraph-style stateful workflows with conditional edges. It implements the Pregel model (Bulk Synchronous Parallel) for deterministic, reproducible execution. + +## Overview + +StateGraph enables: +- **Stateful execution** - Maintain state across nodes +- **Conditional transitions** - Branch based on state +- **Cyclic workflows** - Loops and iterations +- **Composable nodes** - Any Runnable can be a node + +## Quick Start + +```cpp +#include "gopher/orch/graph/state_graph.h" + +using namespace gopher::orch::graph; + +// Define graph +StateGraph graph; +graph + .addNode("agent", agentNode) + .addNode("tools", toolsNode) + .addEdge(StateGraph::START(), "agent") + .addConditionalEdge("agent", [](const GraphState& state) { + if (state.get("should_continue").getBool()) { + return "tools"; + } + return StateGraph::END(); + }) + .addEdge("tools", "agent"); + +// Compile and execute +auto compiled = graph.compile(); +compiled->invoke(initialState, config, dispatcher, callback); +``` + +## GraphState + +State is stored as a JSON-like key-value structure: + +```cpp +GraphState state; + +// Set values +state.set("messages", JsonValue::array()); +state.set("step_count", 0); +state.set("status", "running"); + +// Get values +auto messages = state.get("messages"); +auto count = state.get("step_count").getInt(); + +// Convert to/from JSON +JsonValue json = state.toJson(); +GraphState restored = GraphState::fromJson(json); +``` + +## Adding Nodes + +### Synchronous Lambda + +```cpp +graph.addNode("increment", [](const GraphState& state) { + GraphState result = state; + int count = state.get("count").getInt(); + result.set("count", count + 1); + return result; +}); +``` + +### Async Lambda + +```cpp +graph.addNodeAsync("fetch", [](const GraphState& state, + const RunnableConfig& config, + Dispatcher& dispatcher, + GraphStateCallback callback) { + // Perform async operation + fetchData(state.get("url").getString(), dispatcher, + [state, callback = std::move(callback)](Result result) { + if (mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + return; + } + GraphState newState = state; + newState.set("data", mcp::get(result)); + callback(makeSuccess(std::move(newState))); + }); +}); +``` + +### JsonRunnable Node + +```cpp +// Any JsonRunnable can be a node +auto llmRunnable = makeLLMRunnable(provider, config); +graph.addNode("llm", llmRunnable); + +// The runnable receives state as JSON, returns updates +// Output keys are merged into state +``` + +## Adding Edges + +### Direct Edges + +Always transition from one node to another: + +```cpp +graph.addEdge("start", "process"); // start -> process +graph.addEdge("process", "end"); // process -> end +``` + +### Conditional Edges + +Transition based on state evaluation: + +```cpp +graph.addConditionalEdge("agent", [](const GraphState& state) -> std::string { + auto action = state.get("action").getString(); + + if (action == "search") return "search_node"; + if (action == "calculate") return "calc_node"; + if (action == "done") return StateGraph::END(); + + return "error_node"; // Default +}); +``` + +### Special Nodes + +```cpp +// START - entry point (implicit) +graph.addEdge(StateGraph::START(), "first_node"); + +// END - terminates execution +graph.addEdge("last_node", StateGraph::END()); +``` + +## Execution Model + +StateGraph uses the **Pregel model**: + +1. **PLAN** - Determine which nodes can execute +2. **EXECUTE** - Run scheduled nodes in parallel +3. **UPDATE** - Apply state changes atomically +4. **REPEAT** - Continue until END is reached + +``` +┌─────────────────────────────────────────┐ +│ Execution Loop │ +├─────────────────────────────────────────┤ +│ 1. PLAN: Find ready nodes │ +│ - Check edges from current position │ +│ - Evaluate conditional edges │ +│ │ +│ 2. EXECUTE: Run nodes │ +│ - Execute node functions │ +│ - Collect state updates │ +│ │ +│ 3. UPDATE: Merge state │ +│ - Apply updates atomically │ +│ - Determine next nodes │ +│ │ +│ 4. Check: END reached? │ +│ - Yes: Return final state │ +│ - No: Loop to step 1 │ +└─────────────────────────────────────────┘ +``` + +## ReAct Agent Example + +Build a reasoning agent with tool usage: + +```cpp +StateGraph graph; + +// Agent node - decides what to do +graph.addNode("agent", [&llm](const GraphState& state) { + // Call LLM with messages + auto response = llm->chat(state.get("messages")); + + GraphState result = state; + auto messages = state.get("messages"); + messages.push_back(response.message.toJson()); + result.set("messages", messages); + + // Check if agent wants to use tools + if (response.hasToolCalls()) { + result.set("tool_calls", response.toolCallsJson()); + result.set("should_continue", true); + } else { + result.set("should_continue", false); + } + + return result; +}); + +// Tools node - executes tool calls +graph.addNode("tools", [&executor](const GraphState& state) { + auto calls = state.get("tool_calls"); + auto results = executor->execute(calls); + + GraphState result = state; + auto messages = state.get("messages"); + for (auto& r : results) { + messages.push_back(r.toJson()); + } + result.set("messages", messages); + result.set("tool_calls", JsonValue::null()); + + return result; +}); + +// Wire up the graph +graph.addEdge(StateGraph::START(), "agent") + .addConditionalEdge("agent", [](const GraphState& s) { + return s.get("should_continue").getBool() ? "tools" : StateGraph::END(); + }) + .addEdge("tools", "agent"); + +// Compile and run +auto agent = graph.compile(); +``` + +## Compiled Graph + +The compiled graph is a `Runnable`: + +```cpp +auto compiled = graph.compile(); + +// It's just a Runnable - compose it! +auto withTimeout = withTimeout(compiled, 60000); +auto withRetry = withRetry(compiled, RetryPolicy::exponential(3)); + +// Or put it in a sequence +auto pipeline = sequence() + .add(prepareInput) + .add(compiled) + .add(formatOutput) + .build(); +``` + +## State Reducers + +For custom state merging logic (like LangGraph's `add_messages`): + +```cpp +// Define custom state with reducer +struct AgentState { + std::vector messages; // APPEND semantics + int step_count; // LAST_WRITE_WINS + Usage total_usage; // ACCUMULATE + + // Reducer merges updates into current state + static AgentState reduce(const AgentState& current, + const AgentState& update) { + AgentState result; + + // APPEND: messages + result.messages = current.messages; + for (const auto& msg : update.messages) { + result.messages.push_back(msg); + } + + // LAST_WRITE_WINS: step_count + result.step_count = update.step_count; + + // ACCUMULATE: usage + result.total_usage.prompt_tokens = + current.total_usage.prompt_tokens + update.total_usage.prompt_tokens; + + return result; + } +}; +``` + +## Best Practices + +1. **Keep nodes focused** - Each node should do one thing +2. **Use meaningful node names** - Helps with debugging and tracing +3. **Handle errors in nodes** - Return errors via callback +4. **Avoid shared mutable state** - Let the graph manage state +5. **Test nodes independently** - Unit test before composing +6. **Set max iterations** - Prevent infinite loops + +## Debugging + +```cpp +// Enable step callbacks +auto compiled = graph.compile(); +compiled->setStepCallback([](const std::string& node, const GraphState& state) { + std::cout << "Executed node: " << node << std::endl; + std::cout << "State: " << state.toJson().toString() << std::endl; +}); +``` + +## See Also + +- [Runnable Interface](Runnable.md) - Core interface +- [Agent Framework](Agent.md) - ReAct agents with tools +- [Composition Patterns](Composition.md) - Sequence, Parallel, Router diff --git a/third_party/gopher-orch/docs/ToolRegistry.md b/third_party/gopher-orch/docs/ToolRegistry.md new file mode 100644 index 00000000..459a7543 --- /dev/null +++ b/third_party/gopher-orch/docs/ToolRegistry.md @@ -0,0 +1,485 @@ +# ToolRegistry & ToolExecutor Design Document + +## Overview + +The tool management system is split into two components following the Single Responsibility Principle: + +- **ToolRegistry** - A pure repository that stores and retrieves tool definitions +- **ToolExecutor** - Executes tools by looking them up in a registry + +This separation ensures clean architecture where storage concerns are decoupled from execution logic. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Application / Agent │ +└─────────────────────────────────────────────────────────────────────┘ + │ │ + │ getToolSpecs() │ executeToolCalls() + ▼ ▼ +┌───────────────────────────────┐ ┌───────────────────────────────┐ +│ ToolRegistry │◀──│ ToolExecutor │ +│ (Repository / Storage) │ │ (Execution Logic) │ +├───────────────────────────────┤ ├───────────────────────────────┤ +│ • addTool() │ │ • executeTool() │ +│ • addServer() │ │ • executeToolCall() │ +│ • addSyncTool() │ │ • executeToolCalls() │ +│ • getToolSpecs() │ │ │ +│ • getToolEntry() │ │ Uses registry->getToolEntry() │ +│ • hasTool() │ │ to lookup before execution │ +│ • loadFromFile() │ │ │ +└───────────────────────────────┘ └───────────────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ +│ Local Tools │ │ MCP Server │ │ MCP Server │ │ REST Tools │ +│ (Lambda) │ │ (STDIO) │ │ (HTTP) │ │ (Adapter) │ +└──────────────┘ └────────────┘ └────────────┘ └────────────┘ +``` + +## Core Components + +### 1. ToolRegistry - Repository + +```cpp +class ToolRegistry { + public: + // Registration + void addTool(name, description, parameters, function); + void addSyncTool(name, description, parameters, sync_function); + void addServer(server, dispatcher); + void addServerTool(server, tool_info, alias); + + // Retrieval + std::vector getToolSpecs() const; + optional getToolSpec(name) const; + optional getToolEntry(name) const; + bool hasTool(name) const; + std::vector getToolNames() const; + size_t toolCount() const; + + // Management + void removeTool(name); + void clear(); + + // Configuration + void loadFromFile(path, dispatcher, callback); + void loadFromString(json_string, dispatcher, callback); + void setEnv(name, value); +}; +``` + +### 2. ToolExecutor - Execution + +```cpp +class ToolExecutor { + public: + explicit ToolExecutor(ToolRegistryPtr registry); + + // Get underlying registry + ToolRegistryPtr registry() const; + + // Execute single tool + void executeTool(name, arguments, dispatcher, callback); + + // Execute ToolCall from LLM + void executeToolCall(call, dispatcher, callback); + + // Execute multiple tool calls (parallel) + void executeToolCalls(calls, parallel, dispatcher, callback); +}; +``` + +### 3. ToolEntry - Internal Representation + +```cpp +struct ToolEntry { + ToolSpec spec; // Name, description, parameters + ToolFunction function; // Lambda for local tools + ServerPtr server; // MCP server for remote tools + std::string original_name; // Original name on server + + bool isLocal() const { return server == nullptr; } + bool isRemote() const { return server != nullptr; } +}; +``` + +## Tool Registration Flow + +``` +┌────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Source │────▶│ ToolRegistry │────▶│ ToolEntry │ +└────────────┘ └──────────────┘ └─────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + +╔═══════════════════════════════════════════════════════════════════╗ +║ LOCAL TOOL REGISTRATION ║ +╠═══════════════════════════════════════════════════════════════════╣ +║ ║ +║ registry->addTool("name", "desc", schema, lambda) ║ +║ │ ║ +║ ▼ ║ +║ ┌─────────────────────┐ ║ +║ │ Create ToolEntry │ ║ +║ │ • spec.name = name │ ║ +║ │ • spec.desc = desc │ ║ +║ │ • function = lambda │ ║ +║ │ • server = nullptr │ ║ +║ └─────────────────────┘ ║ +║ │ ║ +║ ▼ ║ +║ tools_[name] = entry ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════╝ + +╔═══════════════════════════════════════════════════════════════════╗ +║ MCP SERVER REGISTRATION ║ +╠═══════════════════════════════════════════════════════════════════╣ +║ ║ +║ registry->addServer(server, dispatcher) ║ +║ │ ║ +║ ▼ ║ +║ ┌────────────────────────┐ ║ +║ │ server->listTools() │──────▶ Async tool discovery ║ +║ └────────────────────────┘ ║ +║ │ ║ +║ ▼ ║ +║ For each ServerToolInfo: ║ +║ ┌─────────────────────────────┐ ║ +║ │ Create ToolEntry │ ║ +║ │ • spec = toToolSpec(info) │ ║ +║ │ • server = server │ ║ +║ │ • original_name = info.name │ ║ +║ └─────────────────────────────┘ ║ +║ │ ║ +║ ▼ ║ +║ tools_["server:name"] = entry (prefixed) ║ +║ tools_["name"] = entry (if no conflict) ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════╝ +``` + +## Tool Execution Flow + +``` +┌─────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ +│ Agent │────▶│ ToolExecutor │────▶│ ToolRegistry │────▶│ Result │ +└─────────┘ └──────────────┘ └──────────────┘ └──────────┘ + │ │ │ │ + │ executeTool() │ │ │ + │ ───────────────▶│ │ │ + │ │ getToolEntry() │ │ + │ │ ──────────────────▶│ │ + │ │ │ │ + │ │◀──────────────────── │ │ + │ │ ToolEntry │ │ + │ │ │ │ + │ │ if entry.isLocal() │ │ + │ │ ┌─────────────────────────────────┐ │ + │ │ │ entry.function(args, dispatcher,│ │ + │ │ │ callback) │ │ + │ │ └─────────────────────────────────┘ │ + │ │ │ │ + │ │ if entry.isRemote()│ │ + │ │ ┌─────────────────────────────────┐ │ + │ │ │ entry.server->callTool( │ │ + │ │ │ original_name, args, │ │ + │ │ │ config, dispatcher, callback) │ │ + │ │ └─────────────────────────────────┘ │ + │ │ │ │ + │ ◀────────────────────────────────────────────────────── │ + │ callback(Result) │ +``` + +## Parallel Tool Execution + +``` +┌─────────┐ ┌──────────────┐ +│ Agent │────▶│ ToolExecutor │ +└─────────┘ └──────────────┘ + │ │ + │ executeToolCalls(calls, parallel=true) + │ ─────────────────────────────────────▶ + │ │ + │ │ ┌─────────────────────────────────────────┐ + │ │ │ Create shared state: │ + │ │ │ • results = vector(calls.size())│ + │ │ │ • pending = atomic(calls.size()) │ + │ │ └─────────────────────────────────────────┘ + │ │ + │ │ For each call (parallel): + │ │ ┌────────────────────────────────────────┐ + │ │ │ registry->getToolEntry(call.name) │ + │ │ │ execute entry.function or server call │ + │ │ │ on completion: results[i] = result │ + │ │ │ if (--pending == 0) │ + │ │ │ callback(results) │ + │ │ └────────────────────────────────────────┘ + │ │ + │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ │ │ Tool 1 │ │ Tool 2 │ │ Tool 3 │ + │ │ │ ───────▶│ │ ───────▶│ │ ───────▶│ + │ │ └─────────┘ └─────────┘ └─────────┘ + │ │ │ │ │ + │ │ └────────────┴────────────┘ + │ │ │ + │ │ All complete: pending == 0 + │ │ │ + │ ◀────────────────────────────────┘ + │ callback(vector>) +``` + +## Example Usage + +### Basic Setup + +```cpp +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/agent/tool_executor.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::core; + +// Create registry and executor +auto registry = makeToolRegistry(); +auto executor = makeToolExecutor(registry); +``` + +### Adding Local Tools + +```cpp +// Async tool with lambda +JsonValue calcSchema = JsonValue::object(); +calcSchema["type"] = "object"; +// ... schema definition ... + +registry->addTool("add", "Add two numbers", calcSchema, + [](const JsonValue& args, Dispatcher& dispatcher, JsonCallback callback) { + double a = args["a"].getDouble(); + double b = args["b"].getDouble(); + + JsonValue result = JsonValue::object(); + result["sum"] = a + b; + + dispatcher.post([callback = std::move(callback), result]() { + callback(Result(result)); + }); + }); + +// Sync tool (wrapper created automatically) +registry->addSyncTool("multiply", "Multiply two numbers", calcSchema, + [](const JsonValue& args) -> Result { + double a = args["a"].getDouble(); + double b = args["b"].getDouble(); + + JsonValue result = JsonValue::object(); + result["product"] = a * b; + return Result(result); + }); +``` + +### Adding MCP Server Tools + +```cpp +#include "gopher/orch/server/mcp_server.h" + +// Create MCP server +auto weatherServer = createMCPServer("weather", "weather-service", {"--port", "8080"}); + +// Connect and add all tools (async discovery) +registry->addServer(weatherServer, dispatcher); + +// Or add specific tools by name +registry->addServerTool(weatherServer, "get_forecast", "forecast"); + +// Or provide tool list directly (sync) +std::vector tools = { + ServerToolInfo{"get_weather", "Get current weather", weatherSchema}, + ServerToolInfo{"get_forecast", "Get weather forecast", forecastSchema} +}; +registry->addServer(weatherServer, tools); +``` + +### Executing Tools + +```cpp +// Execute single tool via executor +JsonValue args = JsonValue::object(); +args["a"] = 10; +args["b"] = 20; + +executor->executeTool("add", args, dispatcher, + [](Result result) { + if (mcp::holds_alternative(result)) { + auto& value = mcp::get(result); + std::cout << "Result: " << value.toString() << std::endl; + } + }); + +// Execute tool call from LLM +ToolCall call("call_123", "search", JsonValue::object()); +call.arguments["query"] = "weather in NYC"; + +executor->executeToolCall(call, dispatcher, + [](Result result) { + // Handle result... + }); + +// Execute multiple tool calls in parallel +std::vector calls = { + ToolCall("call_1", "get_weather", weatherArgs), + ToolCall("call_2", "get_time", timeArgs) +}; + +executor->executeToolCalls(calls, true /* parallel */, dispatcher, + [](std::vector> results) { + for (size_t i = 0; i < results.size(); ++i) { + if (mcp::holds_alternative(results[i])) { + std::cout << "Tool " << i << " result: " + << mcp::get(results[i]).toString() << std::endl; + } + } + }); +``` + +### Using with Agent + +```cpp +#include "gopher/orch/agent/agent.h" +#include "gopher/orch/llm/openai_provider.h" + +// Create components +auto provider = OpenAIProvider::create("sk-..."); +auto registry = makeToolRegistry(); + +// Add tools to registry +registry->addSyncTool("calculator", "Perform math", mathSchema, + [](const JsonValue& args) -> Result { + // Implementation... + }); + +// Create agent with registry +// Agent internally creates its own ToolExecutor +auto agent = ReActAgent::create(provider, registry); + +// Run query - agent will use tools automatically +agent->run("What is 25 * 4?", dispatcher, + [](Result result) { + if (mcp::holds_alternative(result)) { + auto& agentResult = mcp::get(result); + std::cout << "Answer: " << agentResult.response << std::endl; + } + }); +``` + +### Loading from JSON Configuration + +```cpp +// Load from file +registry->loadFromFile("tools.json", dispatcher, + [](VoidResult result) { + if (mcp::holds_alternative(result)) { + std::cout << "Tools loaded successfully!" << std::endl; + } else { + auto& error = mcp::get(result); + std::cerr << "Failed to load: " << error.message << std::endl; + } + }); +``` + +## JSON Configuration Schema + +```json +{ + "name": "registry-name", + "base_url": "https://api.example.com", + "default_headers": { + "User-Agent": "MyApp/1.0" + }, + + "auth_presets": { + "main_api": { + "type": "bearer", + "value": "${API_TOKEN}" + } + }, + + "mcp_servers": [ + { + "name": "weather", + "transport": "stdio", + "command": "/usr/local/bin/weather-server", + "args": ["--format", "json"], + "env": { + "API_KEY": "${WEATHER_API_KEY}" + } + } + ], + + "tools": [ + { + "name": "search_web", + "description": "Search the web for information", + "input_schema": { + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }, + "rest_endpoint": { + "method": "GET", + "url": "${BASE_URL}/search", + "query_params": { "q": "$.query" }, + "response_path": "$.results" + } + }, + { + "name": "get_forecast", + "description": "Get weather forecast from MCP server", + "input_schema": { + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"] + }, + "mcp_reference": { + "server_name": "weather", + "tool_name": "forecast" + } + } + ] +} +``` + +## Thread Safety + +- **ToolRegistry**: Configuration methods (`addTool`, `addServer`) should be called before use. Read methods (`getToolSpecs`, `getToolEntry`) are thread-safe after configuration. +- **ToolExecutor**: All execution methods are thread-safe. +- All callbacks are invoked in the dispatcher thread context. + +## Error Handling + +```cpp +executor->executeTool("nonexistent", args, dispatcher, + [](Result result) { + if (!mcp::holds_alternative(result)) { + auto& error = mcp::get(result); + std::cerr << "Error: " << error.message << std::endl; + } + }); +``` + +## Best Practices + +1. **Separate concerns** - Use ToolRegistry for storage, ToolExecutor for execution +2. **Register tools before starting agent** - Tool discovery is async +3. **Use meaningful tool names** - LLMs use names to decide which tool to call +4. **Provide clear descriptions** - Help LLM understand when to use each tool +5. **Define precise schemas** - Reduce invalid argument errors +6. **Handle errors gracefully** - Tool failures are passed to LLM for recovery +7. **Use prefixed names** for MCP tools to avoid conflicts (`server:tool`) diff --git a/examples/CMakeLists.txt b/third_party/gopher-orch/examples/CMakeLists.txt similarity index 69% rename from examples/CMakeLists.txt rename to third_party/gopher-orch/examples/CMakeLists.txt index 240bb3ee..da64143f 100644 --- a/examples/CMakeLists.txt +++ b/third_party/gopher-orch/examples/CMakeLists.txt @@ -5,3 +5,6 @@ add_subdirectory(hello_world) # MCP Client example (demonstrates gopher-mcp integration) add_subdirectory(mcp_client) + +# SDK examples (ToolsFetcher, agent usage, and gateway server) +add_subdirectory(sdk) diff --git a/third_party/gopher-orch/examples/chatbot/README.md b/third_party/gopher-orch/examples/chatbot/README.md new file mode 100644 index 00000000..2bb97c05 --- /dev/null +++ b/third_party/gopher-orch/examples/chatbot/README.md @@ -0,0 +1,109 @@ +# Multi-turn Conversational Agent Example + +A chatbot that maintains conversation history and can use tools across multiple turns. + +## What This Example Shows + +- Maintaining conversation context across turns +- Building input with message history +- Using tools within conversation flow +- Interactive REPL-style interface +- Conversation reset functionality + +## Running + +```bash +# Build +cd build +make chatbot + +# Run (requires OpenAI API key) +OPENAI_API_KEY=sk-... ./bin/chatbot +``` + +## Expected Output + +``` +Chatbot ready! Type 'quit' to exit, 'reset' to clear history. +======================================== + +You: Hello! +Assistant: Hi there! How can I help you today? + +You: What time is it? + +Assistant: Let me check the time for you. + +[Calling tool: get_time] + +The current time is 2:30 PM. Is there anything else you would like to know? + +You: Remember that my favorite color is blue + +Assistant: [Calling tool: remember] + +I have noted that your favorite color is blue. I will remember this for our conversation. + +You: reset +Conversation reset. + +You: quit + +Goodbye! +``` + +## Code Walkthrough + +### 1. Chatbot Class +```cpp +class Chatbot { + public: + Chatbot(LLMProviderPtr provider, ToolRegistryPtr registry); + void chat(const std::string& user_message, + Dispatcher& dispatcher, + std::function on_response); + void reset(); + private: + std::vector conversation_; +}; +``` + +### 2. Conversation Management +```cpp +// Add user message to history +conversation_.push_back(Message::user(user_message)); + +// Build context from history +JsonValue context = JsonValue::array(); +for (const auto& msg : conversation_) { + JsonValue msg_json = JsonValue::object(); + msg_json["role"] = roleToString(msg.role); + msg_json["content"] = msg.content; + context.push_back(msg_json); +} +``` + +### 3. Interactive Loop +```cpp +while (true) { + std::getline(std::cin, line); + if (line == "quit") break; + if (line == "reset") { + chatbot.reset(); + continue; + } + chatbot.chat(line, dispatcher, on_response); +} +``` + +## Key Concepts + +- **Message History**: Stores all messages for context +- **System Message**: Initial prompt defining assistant behavior +- **Tool Integration**: Tools available across conversation turns +- **Reset**: Clears history while keeping system prompt + +## See Also + +- [Agent Framework](../../docs/Agent.md) +- [Simple Agent Example](../simple_agent/) diff --git a/third_party/gopher-orch/examples/chatbot/main.cc b/third_party/gopher-orch/examples/chatbot/main.cc new file mode 100644 index 00000000..21e32258 --- /dev/null +++ b/third_party/gopher-orch/examples/chatbot/main.cc @@ -0,0 +1,154 @@ +// Multi-turn Conversational Agent Example +// +// Demonstrates a chatbot that maintains conversation history +// and can use tools across multiple turns. + +#include +#include + +#include "gopher/orch/orch.h" + +using namespace gopher::orch; +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +class Chatbot { + public: + Chatbot(LLMProviderPtr provider, ToolRegistryPtr registry) + : provider_(std::move(provider)), registry_(std::move(registry)) { + // Initialize conversation with system message + conversation_.push_back( + Message::system("You are a helpful conversational assistant. " + "You can use tools when needed. " + "Remember context from previous messages.")); + } + + // Process a user message and return the response + void chat(const std::string& user_message, + Dispatcher& dispatcher, + std::function on_response) { + // Add user message to conversation + conversation_.push_back(Message::user(user_message)); + + // Create agent for this turn + auto executor = makeToolExecutor(registry_); + auto agent = AgentRunnable::create( + provider_, executor, AgentConfig("gpt-4").withMaxIterations(5)); + + // Build input with conversation context + JsonValue input = JsonValue::object(); + JsonValue context = JsonValue::array(); + for (const auto& msg : conversation_) { + JsonValue msg_json = JsonValue::object(); + msg_json["role"] = roleToString(msg.role); + msg_json["content"] = msg.content; + context.push_back(msg_json); + } + input["context"] = context; + input["query"] = ""; // Query is already in context + + agent->invoke( + input, RunnableConfig(), dispatcher, + [this, on_response = std::move(on_response)](Result result) { + if (mcp::holds_alternative(result)) { + on_response("Error: " + mcp::get(result).message); + return; + } + + auto& output = mcp::get(result); + std::string response = output["response"].getString(); + + // Add assistant response to conversation history + conversation_.push_back(Message::assistant(response)); + + on_response(response); + }); + } + + // Get conversation history + const std::vector& history() const { return conversation_; } + + // Clear conversation (start fresh) + void reset() { + conversation_.clear(); + conversation_.push_back( + Message::system("You are a helpful conversational assistant.")); + } + + private: + LLMProviderPtr provider_; + ToolRegistryPtr registry_; + std::vector conversation_; +}; + +int main() { + const char* api_key = std::getenv("OPENAI_API_KEY"); + if (!api_key) { + std::cerr << "Error: OPENAI_API_KEY environment variable not set\n"; + return 1; + } + + auto dispatcher = mcp::event::createLibeventDispatcher(); + + // Create provider and registry + auto provider = makeOpenAIProvider(api_key, "gpt-4"); + auto registry = makeToolRegistry(); + + // Add some tools + registry->addSyncTool( + "remember", "Remember a fact for later. Input: {\"fact\": \"...\"}", + JsonValue::object(), [](const JsonValue& args) -> Result { + // In real app, would store to memory + return makeSuccess( + JsonValue("Remembered: " + args["fact"].getString())); + }); + + registry->addSyncTool( + "get_time", "Get current time", JsonValue::object(), + [](const JsonValue&) -> Result { + return makeSuccess(JsonValue("Current time: 2:30 PM")); + }); + + // Create chatbot + Chatbot chatbot(provider, registry); + + std::cout + << "Chatbot ready! Type 'quit' to exit, 'reset' to clear history.\n"; + std::cout << "========================================\n\n"; + + // Interactive loop + std::string line; + while (true) { + std::cout << "You: "; + std::getline(std::cin, line); + + if (line == "quit" || line == "exit") { + break; + } + + if (line == "reset") { + chatbot.reset(); + std::cout << "Conversation reset.\n\n"; + continue; + } + + if (line.empty()) { + continue; + } + + bool done = false; + chatbot.chat(line, *dispatcher, [&done](std::string response) { + std::cout << "\nAssistant: " << response << "\n\n"; + done = true; + }); + + // Run until response received + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + std::cout << "\nGoodbye!\n"; + return 0; +} diff --git a/examples/hello_world/CMakeLists.txt b/third_party/gopher-orch/examples/hello_world/CMakeLists.txt similarity index 100% rename from examples/hello_world/CMakeLists.txt rename to third_party/gopher-orch/examples/hello_world/CMakeLists.txt diff --git a/examples/hello_world/main.cpp b/third_party/gopher-orch/examples/hello_world/main.cpp similarity index 100% rename from examples/hello_world/main.cpp rename to third_party/gopher-orch/examples/hello_world/main.cpp diff --git a/examples/mcp_client/CMakeLists.txt b/third_party/gopher-orch/examples/mcp_client/CMakeLists.txt similarity index 100% rename from examples/mcp_client/CMakeLists.txt rename to third_party/gopher-orch/examples/mcp_client/CMakeLists.txt diff --git a/examples/mcp_client/mcp_client_example.cc b/third_party/gopher-orch/examples/mcp_client/mcp_client_example.cc similarity index 90% rename from examples/mcp_client/mcp_client_example.cc rename to third_party/gopher-orch/examples/mcp_client/mcp_client_example.cc index 28575339..5cc1937a 100644 --- a/examples/mcp_client/mcp_client_example.cc +++ b/third_party/gopher-orch/examples/mcp_client/mcp_client_example.cc @@ -46,7 +46,7 @@ int main(int argc, char* argv[]) { // Create a Tool definition Tool calculator_tool; calculator_tool.name = "calculator"; - calculator_tool.description = make_optional( + calculator_tool.description = mcp::make_optional( std::string("A simple calculator tool for basic arithmetic")); // Create input schema @@ -62,7 +62,7 @@ int main(int argc, char* argv[]) { required_arr.push_back("b"); schema["required"] = required_arr; - calculator_tool.inputSchema = make_optional(schema); + calculator_tool.inputSchema = mcp::make_optional(schema); std::cout << " Created Tool: " << calculator_tool.name << std::endl; if (calculator_tool.description.has_value()) { @@ -76,8 +76,8 @@ int main(int argc, char* argv[]) { sample_resource.uri = "file:///example/data.json"; sample_resource.name = "Example Data"; sample_resource.description = - make_optional(std::string("Sample JSON data resource for testing")); - sample_resource.mimeType = make_optional(std::string("application/json")); + mcp::make_optional(std::string("Sample JSON data resource for testing")); + sample_resource.mimeType = mcp::make_optional(std::string("application/json")); std::cout << "3. MCP Resource:" << std::endl; std::cout << " URI: " << sample_resource.uri << std::endl; @@ -92,15 +92,15 @@ int main(int argc, char* argv[]) { Prompt greeting_prompt; greeting_prompt.name = "greeting"; greeting_prompt.description = - make_optional(std::string("A simple greeting prompt")); + mcp::make_optional(std::string("A simple greeting prompt")); PromptArgument name_arg; name_arg.name = "name"; - name_arg.description = make_optional(std::string("The name to greet")); + name_arg.description = mcp::make_optional(std::string("The name to greet")); name_arg.required = true; greeting_prompt.arguments = - make_optional(std::vector{name_arg}); + mcp::make_optional(std::vector{name_arg}); std::cout << "4. MCP Prompt:" << std::endl; std::cout << " Name: " << greeting_prompt.name << std::endl; diff --git a/third_party/gopher-orch/examples/multi_agent/README.md b/third_party/gopher-orch/examples/multi_agent/README.md new file mode 100644 index 00000000..7c6d8193 --- /dev/null +++ b/third_party/gopher-orch/examples/multi_agent/README.md @@ -0,0 +1,159 @@ +# Multi-Agent Coordination Example + +Demonstrates multiple specialized agents working together on a complex task. + +## What This Example Shows + +- Creating specialized agents with different tools +- Sequential agent coordination +- Passing data between agents +- Building a research-analyze-write pipeline + +## Running + +```bash +# Build +cd build +make multi_agent + +# Run (requires OpenAI API key) +OPENAI_API_KEY=sk-... ./bin/multi_agent +``` + +## Expected Output + +``` +Multi-Agent Coordination Demo +======================================== + +Topic: AI adoption trends in enterprise +---------------------------------------- + +[Phase 1] Research Agent gathering information... + Research complete. + +[Phase 2] Analyzer Agent processing data... + Analysis complete. + +[Phase 3] Writer Agent generating report... + Report generated. + +======================================== +FINAL REPORT: +======================================== +# AI Adoption Trends in Enterprise + +Based on our research and analysis, here are the key findings... + +======================================== +Multi-agent workflow complete. +``` + +## Agent Architecture + +``` + ┌─────────────────┐ + │ Coordinator │ + └────────┬────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Researcher │ │ Analyzer │ │ Writer │ +│ │ │ │ │ │ +│ Tools: │ │ Tools: │ │ Tools: │ +│ - search_web │ │ - calc_stats │ │ - format_report │ +│ - fetch_data │ │ - id_trends │ │ │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └───────► Data ─────┴───────► Output ───┘ +``` + +## Code Walkthrough + +### 1. Create Specialized Agent +```cpp +auto researcher = createSpecializedAgent( + provider, + "Researcher", + "You are a research specialist. Your job is to gather information " + "using search and data fetching tools.", + researchTools); +``` + +### 2. Agent-Specific Tools +```cpp +auto researchTools = makeToolRegistry(); +researchTools->addSyncTool( + "search_web", + "Search the web for information", + JsonValue::object(), + [](const JsonValue& args) -> Result { + // Search implementation + }); +``` + +### 3. Sequential Coordination +```cpp +// Phase 1: Research +researcher->invoke(researchQuery, config, dispatcher, + [&researchResult](Result result) { + researchResult = mcp::get(result); + }); + +// Phase 2: Analysis (uses research results) +JsonValue analysisInput; +analysisInput["research"] = researchResult; +analyzer->invoke(analysisInput, config, dispatcher, callback); + +// Phase 3: Writing (uses both research and analysis) +JsonValue writerInput; +writerInput["research"] = researchResult; +writerInput["analysis"] = analysisResult; +writer->invoke(writerInput, config, dispatcher, callback); +``` + +## Agent Roles + +| Agent | Purpose | Tools | +|-------|---------|-------| +| Researcher | Gather information | search_web, fetch_data | +| Analyzer | Process and analyze data | calculate_stats, identify_trends | +| Writer | Generate reports | format_report | + +## Coordination Patterns + +### Sequential Pipeline +``` +Researcher → Analyzer → Writer +``` +Each agent receives output from previous agents. + +### Parallel Execution (Alternative) +```cpp +// Run research and analysis in parallel +auto parallel = makeParallel({researcher, analyzer}); +parallel->invoke(input, config, dispatcher, callback); +``` + +### Supervisor Pattern (Alternative) +```cpp +// Supervisor decides which agent to call +auto supervisor = makeSupervisorAgent( + {researcher, analyzer, writer}, + supervisorPrompt); +``` + +## Key Concepts + +- **Specialization**: Each agent has focused capabilities +- **Tool Isolation**: Agents only access their own tools +- **Data Flow**: Results passed between agents +- **Coordination**: Sequential or parallel execution + +## See Also + +- [Agent Framework](../../docs/Agent.md) +- [Composition Patterns](../../docs/Composition.md) +- [Simple Agent Example](../simple_agent/) diff --git a/third_party/gopher-orch/examples/multi_agent/main.cc b/third_party/gopher-orch/examples/multi_agent/main.cc new file mode 100644 index 00000000..1c3f1182 --- /dev/null +++ b/third_party/gopher-orch/examples/multi_agent/main.cc @@ -0,0 +1,257 @@ +// Multi-Agent Coordination Example +// +// Demonstrates multiple specialized agents working together: +// - Researcher agent: Gathers information +// - Analyzer agent: Analyzes data +// - Writer agent: Generates reports +// - Coordinator: Orchestrates the workflow + +#include +#include + +#include "gopher/orch/orch.h" + +using namespace gopher::orch; +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +// Agent task result +struct AgentResult { + std::string agent_name; + std::string output; + int tokens_used; +}; + +// Create a specialized agent with specific tools and prompt +AgentRunnablePtr createSpecializedAgent(LLMProviderPtr provider, + const std::string& name, + const std::string& system_prompt, + ToolRegistryPtr tools) { + return AgentRunnable::create(provider, makeToolExecutor(tools), + AgentConfig("gpt-4") + .withSystemPrompt(system_prompt) + .withMaxIterations(3)); +} + +int main() { + const char* api_key = std::getenv("OPENAI_API_KEY"); + if (!api_key) { + std::cerr << "Error: OPENAI_API_KEY environment variable not set\n"; + return 1; + } + + auto dispatcher = mcp::event::createLibeventDispatcher(); + auto provider = makeOpenAIProvider(api_key, "gpt-4"); + + std::cout << "Multi-Agent Coordination Demo\n"; + std::cout << "========================================\n\n"; + + // ========================================================================= + // Create specialized agents with their tools + // ========================================================================= + + // 1. Researcher Agent - gathers information + auto researchTools = makeToolRegistry(); + researchTools->addSyncTool( + "search_web", + "Search the web for information. Input: {\"query\": \"...\"}", + JsonValue::object(), [](const JsonValue& args) -> Result { + auto query = args["query"].getString(); + JsonValue results = JsonValue::object(); + results["query"] = query; + results["findings"] = JsonValue::array({ + JsonValue("Finding 1: " + query + " shows positive trends"), + JsonValue("Finding 2: Market data indicates growth"), + JsonValue("Finding 3: Expert opinions are mixed"), + }); + return makeSuccess(std::move(results)); + }); + + researchTools->addSyncTool( + "fetch_data", "Fetch data from a source. Input: {\"source\": \"...\"}", + JsonValue::object(), [](const JsonValue& args) -> Result { + auto source = args["source"].getString(); + JsonValue data = JsonValue::object(); + data["source"] = source; + data["data"] = JsonValue::array({ + JsonValue(42.5), + JsonValue(38.2), + JsonValue(45.8), + JsonValue(51.3), + }); + return makeSuccess(std::move(data)); + }); + + auto researcher = createSpecializedAgent( + provider, "Researcher", + "You are a research specialist. Your job is to gather information " + "using search and data fetching tools. Be thorough and systematic.", + researchTools); + + // 2. Analyzer Agent - analyzes data + auto analyzerTools = makeToolRegistry(); + analyzerTools->addSyncTool( + "calculate_stats", + "Calculate statistics on data. Input: {\"values\": [...]}", + JsonValue::object(), [](const JsonValue& args) -> Result { + auto& values = args["values"]; + double sum = 0; + double min = 1e9, max = -1e9; + int count = 0; + + for (size_t i = 0; i < values.size(); i++) { + double val = values[i].getFloat(); + sum += val; + if (val < min) + min = val; + if (val > max) + max = val; + count++; + } + + JsonValue stats = JsonValue::object(); + stats["count"] = count; + stats["sum"] = sum; + stats["average"] = count > 0 ? sum / count : 0; + stats["min"] = min; + stats["max"] = max; + return makeSuccess(std::move(stats)); + }); + + analyzerTools->addSyncTool( + "identify_trends", "Identify trends in data. Input: {\"data\": [...]}", + JsonValue::object(), [](const JsonValue& args) -> Result { + JsonValue trends = JsonValue::object(); + trends["trend"] = "upward"; + trends["confidence"] = 0.85; + trends["insight"] = "Data shows consistent growth pattern"; + return makeSuccess(std::move(trends)); + }); + + auto analyzer = createSpecializedAgent( + provider, "Analyzer", + "You are a data analyst. Your job is to analyze data, calculate " + "statistics, and identify trends. Provide clear insights.", + analyzerTools); + + // 3. Writer Agent - generates reports + auto writerTools = makeToolRegistry(); + writerTools->addSyncTool( + "format_report", + "Format content as a report. Input: {\"title\": \"...\", \"sections\": " + "[...]}", + JsonValue::object(), [](const JsonValue& args) -> Result { + std::string report = "# " + args["title"].getString() + "\n\n"; + auto& sections = args["sections"]; + for (size_t i = 0; i < sections.size(); i++) { + report += "## Section " + std::to_string(i + 1) + "\n"; + report += sections[i].getString() + "\n\n"; + } + JsonValue result = JsonValue::object(); + result["report"] = report; + return makeSuccess(std::move(result)); + }); + + auto writer = createSpecializedAgent( + provider, "Writer", + "You are a technical writer. Your job is to create clear, " + "well-structured reports from research and analysis results.", + writerTools); + + // ========================================================================= + // Orchestrate multi-agent workflow + // ========================================================================= + + std::string topic = "AI adoption trends in enterprise"; + + std::cout << "Topic: " << topic << "\n"; + std::cout << "----------------------------------------\n\n"; + + // Step 1: Research Phase + std::cout << "[Phase 1] Research Agent gathering information...\n"; + JsonValue researchResult; + { + bool done = false; + JsonValue input = JsonValue::object(); + input["query"] = "Research: " + topic; + + researcher->invoke(input, RunnableConfig(), *dispatcher, + [&done, &researchResult](Result result) { + if (mcp::holds_alternative(result)) { + std::cerr << "Research failed: " + << mcp::get(result).message << "\n"; + } else { + researchResult = mcp::get(result); + std::cout << " Research complete.\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + // Step 2: Analysis Phase + std::cout << "\n[Phase 2] Analyzer Agent processing data...\n"; + JsonValue analysisResult; + { + bool done = false; + JsonValue input = JsonValue::object(); + input["research"] = researchResult; + input["query"] = "Analyze the research findings"; + + analyzer->invoke(input, RunnableConfig(), *dispatcher, + [&done, &analysisResult](Result result) { + if (mcp::holds_alternative(result)) { + std::cerr << "Analysis failed: " + << mcp::get(result).message << "\n"; + } else { + analysisResult = mcp::get(result); + std::cout << " Analysis complete.\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + // Step 3: Writing Phase + std::cout << "\n[Phase 3] Writer Agent generating report...\n"; + { + bool done = false; + JsonValue input = JsonValue::object(); + input["research"] = researchResult; + input["analysis"] = analysisResult; + input["query"] = "Create a report on: " + topic; + + writer->invoke( + input, RunnableConfig(), *dispatcher, + [&done](Result result) { + if (mcp::holds_alternative(result)) { + std::cerr << "Writing failed: " << mcp::get(result).message + << "\n"; + } else { + auto& output = mcp::get(result); + std::cout << " Report generated.\n\n"; + std::cout << "========================================\n"; + std::cout << "FINAL REPORT:\n"; + std::cout << "========================================\n"; + std::cout << output["response"].getString() << "\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + std::cout << "\n========================================\n"; + std::cout << "Multi-agent workflow complete.\n"; + + return 0; +} diff --git a/third_party/gopher-orch/examples/resilient_api/README.md b/third_party/gopher-orch/examples/resilient_api/README.md new file mode 100644 index 00000000..cc7b4fc0 --- /dev/null +++ b/third_party/gopher-orch/examples/resilient_api/README.md @@ -0,0 +1,127 @@ +# Resilient API Client Example + +Demonstrates resilience patterns for handling unreliable external services. + +## What This Example Shows + +- Retry with exponential backoff +- Timeout protection +- Fallback on failure +- Circuit breaker for failure isolation +- Combining multiple resilience patterns + +## Running + +```bash +# Build +cd build +make resilient_api + +# Run +./bin/resilient_api +``` + +## Expected Output + +``` +Resilient API Client Demo +======================================== + +1. Retry Pattern (max 3 attempts, exponential backoff) +---------------------------------------- + Success: Response from /api/data + +2. Timeout Pattern (150ms timeout) +---------------------------------------- + Timeout or error: Operation timed out + +3. Fallback Pattern +---------------------------------------- + Got data: Cached fallback data for /api/unreliable + +4. Circuit Breaker Pattern +---------------------------------------- + Call 1: Failed: Connection failed + Call 2: Failed: Connection failed + Call 3: Failed: Connection failed + Call 4: Circuit OPEN - call rejected + Call 5: Circuit OPEN - call rejected + Call 6: Circuit OPEN - call rejected + +5. Combined Resilience (Retry + Timeout + Fallback) +---------------------------------------- + Got data: Response from /api/important + +======================================== +Demo complete. +``` + +## Resilience Patterns + +### 1. Retry with Backoff +```cpp +auto retryConfig = RetryConfig() + .withMaxAttempts(3) + .withInitialDelay(std::chrono::milliseconds(100)) + .withMaxDelay(std::chrono::milliseconds(1000)) + .withBackoffMultiplier(2.0); + +auto retryableApi = makeRetry(apiCall, retryConfig); +``` + +### 2. Timeout Protection +```cpp +auto timedApi = makeTimeout(slowApi, std::chrono::milliseconds(150)); +``` + +### 3. Fallback on Failure +```cpp +auto safeApi = makeFallback(unreliableApi, fallbackApi); +``` + +### 4. Circuit Breaker +```cpp +auto cbConfig = CircuitBreakerConfig() + .withFailureThreshold(3) // Open after 3 failures + .withSuccessThreshold(2) // Close after 2 successes + .withTimeout(std::chrono::seconds(5)); // Half-open after 5s + +auto protectedApi = makeCircuitBreaker(apiCall, cbConfig); +``` + +### 5. Combined Patterns +```cpp +// Build defense-in-depth: retry -> timeout -> fallback +auto combinedApi = makeFallback( + makeTimeout( + makeRetry(apiCall, RetryConfig().withMaxAttempts(2)), + std::chrono::milliseconds(300)), + fallbackApi); +``` + +## Key Concepts + +- **Retry**: Automatically retry failed operations with configurable backoff +- **Timeout**: Bound operation duration to prevent hanging +- **Fallback**: Provide degraded response when primary fails +- **Circuit Breaker**: Stop calling failing services to allow recovery + +## Circuit Breaker States + +``` + ┌─────────────────────────────────────┐ + │ │ + ▼ │ + CLOSED ──(failures >= threshold)──► OPEN + ▲ │ + │ │ + │ (timeout expires) + │ │ + │ ▼ + └───(successes >= threshold)─── HALF_OPEN +``` + +## See Also + +- [Resilience Patterns](../../docs/Resilience.md) +- [Runnable Interface](../../docs/Runnable.md) diff --git a/third_party/gopher-orch/examples/resilient_api/main.cc b/third_party/gopher-orch/examples/resilient_api/main.cc new file mode 100644 index 00000000..9a9c1630 --- /dev/null +++ b/third_party/gopher-orch/examples/resilient_api/main.cc @@ -0,0 +1,277 @@ +// Resilient API Client Example +// +// Demonstrates resilience patterns for external API calls: +// - Retry with exponential backoff +// - Timeout protection +// - Fallback on failure +// - Circuit breaker for failure isolation + +#include +#include +#include + +#include "gopher/orch/orch.h" + +using namespace gopher::orch; +using namespace gopher::orch::core; +using namespace gopher::orch::resilience; + +// Simulated API response +struct ApiResponse { + bool success; + std::string data; + int latency_ms; +}; + +// Simulated unreliable API client +class UnreliableApiClient { + public: + UnreliableApiClient(double failure_rate = 0.5, int max_latency_ms = 500) + : failure_rate_(failure_rate), + max_latency_ms_(max_latency_ms), + gen_(std::random_device{}()) {} + + // Simulates an API call that may fail or be slow + void fetch(const std::string& endpoint, + Dispatcher& dispatcher, + std::function)> callback) { + std::uniform_real_distribution<> fail_dist(0.0, 1.0); + std::uniform_int_distribution<> latency_dist(10, max_latency_ms_); + + bool will_fail = fail_dist(gen_) < failure_rate_; + int latency = latency_dist(gen_); + + // Simulate network latency + dispatcher.setTimeout( + [this, endpoint, will_fail, latency, callback = std::move(callback)]() { + if (will_fail) { + callback(makeOrchError( + OrchError::NETWORK_ERROR, "Connection failed to " + endpoint)); + } else { + ApiResponse response; + response.success = true; + response.data = "Response from " + endpoint; + response.latency_ms = latency; + callback(makeSuccess(std::move(response))); + } + }, + std::chrono::milliseconds(latency)); + } + + void setFailureRate(double rate) { failure_rate_ = rate; } + + private: + double failure_rate_; + int max_latency_ms_; + std::mt19937 gen_; +}; + +// Create a runnable from the API client +RunnablePtr makeApiRunnable( + std::shared_ptr client) { + return makeLambda( + [client](const std::string& endpoint, Dispatcher& dispatcher, + ResultCallback callback) { + client->fetch(endpoint, dispatcher, std::move(callback)); + }); +} + +int main() { + auto dispatcher = mcp::event::createLibeventDispatcher(); + + // Create unreliable API client (50% failure rate) + auto client = std::make_shared(0.5, 200); + auto apiCall = makeApiRunnable(client); + + std::cout << "Resilient API Client Demo\n"; + std::cout << "========================================\n\n"; + + // ========================================================================= + // Pattern 1: Retry with Exponential Backoff + // ========================================================================= + std::cout << "1. Retry Pattern (max 3 attempts, exponential backoff)\n"; + std::cout << "----------------------------------------\n"; + + auto retryConfig = RetryConfig() + .withMaxAttempts(3) + .withInitialDelay(std::chrono::milliseconds(100)) + .withMaxDelay(std::chrono::milliseconds(1000)) + .withBackoffMultiplier(2.0); + + auto retryableApi = makeRetry(apiCall, retryConfig); + + { + bool done = false; + int attempt = 0; + retryableApi->invoke( + "/api/data", RunnableConfig(), *dispatcher, + [&done, &attempt](Result result) { + if (mcp::holds_alternative(result)) { + std::cout << " Failed after retries: " + << mcp::get(result).message << "\n"; + } else { + auto& response = mcp::get(result); + std::cout << " Success: " << response.data << "\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + // ========================================================================= + // Pattern 2: Timeout Protection + // ========================================================================= + std::cout << "\n2. Timeout Pattern (150ms timeout)\n"; + std::cout << "----------------------------------------\n"; + + // Create slow API (high latency) + auto slowClient = std::make_shared(0.0, 500); + auto slowApi = makeApiRunnable(slowClient); + auto timedApi = makeTimeout(slowApi, std::chrono::milliseconds(150)); + + { + bool done = false; + timedApi->invoke("/api/slow", RunnableConfig(), *dispatcher, + [&done](Result result) { + if (mcp::holds_alternative(result)) { + std::cout << " Timeout or error: " + << mcp::get(result).message << "\n"; + } else { + auto& response = mcp::get(result); + std::cout + << " Success (within timeout): " << response.data + << "\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + // ========================================================================= + // Pattern 3: Fallback on Failure + // ========================================================================= + std::cout << "\n3. Fallback Pattern\n"; + std::cout << "----------------------------------------\n"; + + // Create always-failing API + auto failingClient = std::make_shared(1.0, 50); + auto failingApi = makeApiRunnable(failingClient); + + // Create fallback that returns cached data + auto fallbackApi = makeLambda( + [](const std::string& endpoint, Dispatcher& dispatcher, + ResultCallback callback) { + ApiResponse cached; + cached.success = true; + cached.data = "Cached fallback data for " + endpoint; + cached.latency_ms = 0; + callback(makeSuccess(std::move(cached))); + }); + + auto safeApi = makeFallback(failingApi, fallbackApi); + + { + bool done = false; + safeApi->invoke( + "/api/unreliable", RunnableConfig(), *dispatcher, + [&done](Result result) { + if (mcp::holds_alternative(result)) { + std::cout << " Error: " << mcp::get(result).message << "\n"; + } else { + auto& response = mcp::get(result); + std::cout << " Got data: " << response.data << "\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + // ========================================================================= + // Pattern 4: Circuit Breaker + // ========================================================================= + std::cout << "\n4. Circuit Breaker Pattern\n"; + std::cout << "----------------------------------------\n"; + + auto cbConfig = CircuitBreakerConfig() + .withFailureThreshold(3) + .withSuccessThreshold(2) + .withTimeout(std::chrono::seconds(5)); + + // Reset client to 70% failure rate for circuit breaker demo + client->setFailureRate(0.7); + auto protectedApi = makeCircuitBreaker(apiCall, cbConfig); + + // Make multiple calls to trigger circuit breaker + for (int i = 1; i <= 6; i++) { + bool done = false; + std::cout << " Call " << i << ": "; + + protectedApi->invoke( + "/api/fragile", RunnableConfig(), *dispatcher, + [&done](Result result) { + if (mcp::holds_alternative(result)) { + const auto& err = mcp::get(result); + if (err.message.find("Circuit open") != std::string::npos) { + std::cout << "Circuit OPEN - call rejected\n"; + } else { + std::cout << "Failed: " << err.message << "\n"; + } + } else { + std::cout << "Success\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + // ========================================================================= + // Pattern 5: Combined Resilience + // ========================================================================= + std::cout << "\n5. Combined Resilience (Retry + Timeout + Fallback)\n"; + std::cout << "----------------------------------------\n"; + + // Reset client for combined demo + client->setFailureRate(0.3); + + auto combinedApi = makeFallback( + makeTimeout(makeRetry(apiCall, RetryConfig().withMaxAttempts(2)), + std::chrono::milliseconds(300)), + fallbackApi); + + { + bool done = false; + combinedApi->invoke( + "/api/important", RunnableConfig(), *dispatcher, + [&done](Result result) { + if (mcp::holds_alternative(result)) { + std::cout << " Final error: " << mcp::get(result).message + << "\n"; + } else { + auto& response = mcp::get(result); + std::cout << " Got data: " << response.data << "\n"; + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + std::cout << "\n========================================\n"; + std::cout << "Demo complete.\n"; + + return 0; +} diff --git a/third_party/gopher-orch/examples/sdk/CMakeLists.txt b/third_party/gopher-orch/examples/sdk/CMakeLists.txt new file mode 100644 index 00000000..f47c113e --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/CMakeLists.txt @@ -0,0 +1,113 @@ +# SDK Examples + +# Client example demonstrating ToolsFetcher usage +add_executable(client_example client_example.cpp) +target_link_libraries(client_example + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(client_example PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) + +# JSON client example using the static Agent::run() method with JSON config +add_executable(client_example_json client_example_json.cpp) +target_link_libraries(client_example_json + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(client_example_json PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) + +# API client example demonstrating remote API integration +add_executable(client_example_api client_example_api.cpp) +target_link_libraries(client_example_api + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(client_example_api PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) + +# Test for error response handling +add_executable(test_error_response test_error_response.cpp) +target_link_libraries(test_error_response + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(test_error_response PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) + +# Gateway server example - MCP server aggregating multiple backend servers +add_executable(gateway_server_example gateway_server_example.cpp) +target_link_libraries(gateway_server_example + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(gateway_server_example PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) + +# Gateway server test client - connects to gateway_server_example +add_executable(gateway_server_example_test gateway_server_example_test.cpp) +target_link_libraries(gateway_server_example_test + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(gateway_server_example_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) + +# JSON client example for listing tools from MCP servers +add_executable(client_example_json_list_tool client_example_json_list_tool.cpp) +target_link_libraries(client_example_json_list_tool + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(client_example_json_list_tool PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) + +# JSON client example for listing tools using ReActAgent +add_executable(client_example_json_list_tool_agent client_example_json_list_tool_agent.cpp) +target_link_libraries(client_example_json_list_tool_agent + gopher-orch + ${GOPHER_MCP_LIBRARIES} + Threads::Threads +) + +# Set runtime path for finding shared libraries +set_target_properties(client_example_json_list_tool_agent PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/examples/sdk" + INSTALL_RPATH_USE_LINK_PATH TRUE +) \ No newline at end of file diff --git a/third_party/gopher-orch/examples/sdk/client_example.cpp b/third_party/gopher-orch/examples/sdk/client_example.cpp new file mode 100644 index 00000000..899b763b --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/client_example.cpp @@ -0,0 +1,217 @@ +/** + * @file client_example.cpp + * @brief Basic MCP client example with ReActAgent + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mcp/event/libevent_dispatcher.h" +#include "gopher/orch/agent/tools_fetcher.h" +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/agent/agent.h" +#include "gopher/orch/llm/anthropic_provider.h" + +using namespace gopher::orch; +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; + +int main(int argc, char* argv[]) { + std::cout << "=== ToolsFetcher Basic Example ===" << std::endl; + std::cout << "Usage: " << argv[0] << " [query]" << std::endl; + std::cout << "Example: " << argv[0] << " \"What is the weather in New York?\"" << std::endl; + std::cout << "Default query: \"What tools are available?\"" << std::endl << std::endl; + + auto dispatcher = std::make_unique("client_example"); + + // MCP server configuration + std::string json_config = R"({ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "server1", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3001/rpc", + "headers": {} + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + }, + { + "version": "2025-01-09", + "serverId": "1877234567890123457", + "name": "server2", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3002/rpc", + "headers": {} + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + } + ] + } + })"; + + auto tools_fetcher = std::make_unique(); + + std::cout << "Loading MCP server configuration..." << std::endl; + + bool load_complete = false; + bool load_success = false; + + tools_fetcher->loadFromJson(json_config, *dispatcher, + [&load_complete, &load_success](VoidResult result) { + std::cout << "loadFromJson callback received" << std::endl; + load_complete = true; + if (mcp::holds_alternative(result)) { + load_success = true; + std::cout << "Configuration loaded successfully!" << std::endl; + } else { + std::cerr << "Failed to load configuration: " + << mcp::get(result).message << std::endl; + } + }); + + // Wait for loading to complete + auto load_start = std::chrono::steady_clock::now(); + auto load_timeout = std::chrono::seconds(10); + + while (!load_complete) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + if (std::chrono::steady_clock::now() - load_start > load_timeout) { + std::cerr << "Configuration loading timed out after 10 seconds." << std::endl; + std::cerr << "This usually means the MCP servers are not responding." << std::endl; + return 1; + } + } + + if (!load_success) { + std::cerr << "\nConfiguration failed. Common causes:" << std::endl; + std::cerr << "1. No MCP servers running at the configured URLs" << std::endl; + std::cerr << "2. Servers are not MCP-compliant (need SSE transport)" << std::endl; + std::cerr << "3. Network/firewall issues" << std::endl; + std::cerr << "\nTo run MCP servers, try:" << std::endl; + std::cerr << " npx @modelcontextprotocol/server-everything --port 3001" << std::endl; + + std::cerr << "Exiting due to connection failure." << std::endl; + _exit(1); + } + + // Allow async operations to complete + for (int i = 0; i < 10; i++) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + auto registry = tools_fetcher->getRegistry(); + if (!registry) { + std::cerr << "Failed to get ToolRegistry!" << std::endl; + return 1; + } + + std::cout << "Discovered " << registry->toolCount() << " tools" << std::endl; + + // Show available tools + auto tool_specs = registry->getToolSpecs(); + std::cout << "\nAvailable tools:" << std::endl; + for (const auto& spec : tool_specs) { + std::cout << " - " << spec.name << ": " << spec.description << std::endl; + std::cout << " Parameters: " << spec.parameters.toString() << std::endl; + } + + if (registry->toolCount() == 0) { + std::cerr << "\nNo tools discovered. Please ensure MCP servers are running." << std::endl; + return 1; + } + + // Setup LLM provider + const char* api_key = std::getenv("ANTHROPIC_API_KEY"); + if (!api_key || std::strlen(api_key) == 0) { + std::cerr << "ANTHROPIC_API_KEY environment variable not set!" << std::endl; + return 1; + } + auto llm_provider = AnthropicProvider::create(api_key); + + // Create agent + AgentConfig agent_config("claude-3-haiku-20240307"); + agent_config.withSystemPrompt( + "You are a helpful assistant with access to various tools. " + "Use the appropriate tools to complete tasks. " + "Always explain your reasoning before taking action."); + agent_config.withMaxIterations(5); + agent_config.withTemperature(0.3); + + auto agent = ReActAgent::create(llm_provider, registry, agent_config); + if (!agent) { + std::cerr << "Failed to create ReActAgent!" << std::endl; + return 1; + } + + // Get query + std::string query = "What time is it in Tokyo?"; + if (argc > 1) { + query = ""; + for (int i = 1; i < argc; i++) { + if (i > 1) query += " "; + query += argv[i]; + } + } + std::cout << "\nQuery: " << query << std::endl; + std::cout << "Running agent..." << std::endl; + + std::promise completion_promise; + auto completion_future = completion_promise.get_future(); + + agent->run(query, *dispatcher, + [&completion_promise](Result result) { + if (mcp::holds_alternative(result)) { + auto response = mcp::get(result); + std::cout << "\nAgent Response:" << std::endl; + std::cout << "--------------------------------" << std::endl; + std::cout << "\n" << response.response << std::endl; + std::cout << "\n--------------------------------" << std::endl; + std::cout << "Total steps: " << response.iterationCount() << std::endl; + std::cout << "Status: " << agentStatusToString(response.status) << std::endl; + if (response.total_usage.total_tokens > 0) { + std::cout << "Tokens used: " << response.total_usage.total_tokens << std::endl; + } + completion_promise.set_value(0); + } else { + std::cerr << "Agent error: " + << mcp::get(result).message << std::endl; + completion_promise.set_value(0); + } + }); + + // Run with timeout + auto start_time = std::chrono::steady_clock::now(); + auto timeout_duration = std::chrono::seconds(30); + + while (completion_future.wait_for(std::chrono::milliseconds(10)) != std::future_status::ready) { + if (std::chrono::steady_clock::now() - start_time > timeout_duration) { + std::cerr << "Query timed out!" << std::endl; + std::cout << "\n=== Example Complete (Timeout) ===" << std::endl; + _exit(1); + } + + dispatcher->run(mcp::event::RunType::NonBlock); + } + + std::cout << "\n=== Example Complete ===" << std::endl; + exit(0); +} \ No newline at end of file diff --git a/third_party/gopher-orch/examples/sdk/client_example_api.cpp b/third_party/gopher-orch/examples/sdk/client_example_api.cpp new file mode 100644 index 00000000..afe1bdcd --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/client_example_api.cpp @@ -0,0 +1,62 @@ +/** + * @file client_example_api.cpp + * @brief Simple MCP client example that fetches server configurations from remote API + */ + +#include +#include + +#include "gopher/orch/agent/agent.h" + +using namespace gopher::orch::agent; + +int main(int argc, char* argv[]) { + std::cout << "=== Remote API MCP Client Example ===" << std::endl; + std::cout << "Usage: " << argv[0] << " [query1] [query2] ..." << std::endl; + std::cout << "Example: " << argv[0] << " \"What tools are available?\" \"What time is it in Tokyo?\"" << std::endl; + std::cout << "Default queries if none provided:" << std::endl; + std::cout << " 1. What tools are available?" << std::endl; + std::cout << " 2. What time is it in Tokyo?" << std::endl << std::endl; + + // Parse queries from command line arguments + std::vector queries; + if (argc > 1) { + for (int i = 1; i < argc; i++) { + queries.push_back(argv[i]); + } + } else { + // Default queries if none provided + queries.push_back("What tools are available?"); + queries.push_back("What time is it in Tokyo?"); + } + + std::string provider = "AnthropicProvider"; + std::string model = "claude-3-haiku-20240307"; + std::string apiKey = "sk_xkmdfiw3jfndeaypegwb"; + + std::cout << "Provider: " << provider << std::endl; + std::cout << "Model: " << model << std::endl; + std::cout << "apiKey: " << apiKey << std::endl; + std::cout << "Number of queries: " << queries.size() << std::endl; + std::cout << "Creating agent with API key..." << std::endl; + + auto agent = ReActAgent::createByApiKey(provider, model, apiKey); + if (!agent) { + std::cout << "Error: Failed to create agent" << std::endl; + return 1; + } + std::cout << "Agent created successfully!" << std::endl; + + // Execute all queries + for (size_t i = 0; i < queries.size(); i++) { + std::cout << "\nQuery " << (i + 1) << ": " << queries[i] << std::endl; + + std::string answer = agent->run(queries[i]); + std::cout << "\nAgent Response " << (i + 1) << ":" << std::endl; + std::cout << "--------------------------------" << std::endl; + std::cout << answer << std::endl; + std::cout << "--------------------------------" << std::endl; + } + + return 0; +} \ No newline at end of file diff --git a/third_party/gopher-orch/examples/sdk/client_example_json.cpp b/third_party/gopher-orch/examples/sdk/client_example_json.cpp new file mode 100644 index 00000000..d541e66c --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/client_example_json.cpp @@ -0,0 +1,95 @@ +/** + * @file client_example_json.cpp + * @brief MCP client example using JSON config with the static Agent::run() method + */ + +#include +#include + +#include "gopher/orch/agent/agent.h" + +using namespace gopher::orch::agent; + +int main(int argc, char* argv[]) { + std::cout << "=== Simple Agent Example ===" << std::endl; + std::cout << "Usage: " << argv[0] << " [query1] [query2] [query3] ..." << std::endl; + std::cout << "Example: " << argv[0] << " \"What time is it in Tokyo?\" \"Generate a 12-character password\"" << std::endl; + std::cout << "Default queries if none provided:" << std::endl; + std::cout << " 1. What time is it in Tokyo?" << std::endl; + std::cout << " 2. Generate a 12-character password" << std::endl << std::endl; + + std::string provider = "AnthropicProvider"; + std::string model = "claude-3-haiku-20240307"; + std::string serverJson = R"({ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "gopher-auth-server", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3001/rpc", + "headers": {} + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + }, + { + "version": "2025-01-09", + "serverId": "1877234567890123457", + "name": "gopher-auth-server2", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3002/rpc", + "headers": {} + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + } + ] + } + })"; + + // Parse queries from command line arguments + std::vector queries; + if (argc > 1) { + // Use provided queries + for (int i = 1; i < argc; i++) { + queries.push_back(argv[i]); + } + } else { + // Default queries if none provided + queries.push_back("What time is it in Tokyo?"); + queries.push_back("Generate a 12-character password"); + } + + std::cout << "Provider: " << provider << std::endl; + std::cout << "Model: " << model << std::endl; + std::cout << "Number of queries: " << queries.size() << std::endl; + std::cout << "Creating agent..." << std::endl; + + auto agent = ReActAgent::createByJson(provider, model, serverJson); + if (!agent) { + std::cout << "Error: Failed to create agent" << std::endl; + return 1; + } + + std::cout << "Agent created successfully!" << std::endl; + + // Execute all queries + for (size_t i = 0; i < queries.size(); i++) { + std::cout << "\nQuery " << (i + 1) << ": " << queries[i] << std::endl; + + std::string answer = agent->run(queries[i]); + std::cout << "\nAgent Response " << (i + 1) << ":" << std::endl; + std::cout << "--------------------------------" << std::endl; + std::cout << answer << std::endl; + std::cout << "--------------------------------" << std::endl; + } + + return 0; +} \ No newline at end of file diff --git a/third_party/gopher-orch/examples/sdk/client_example_json_list_tool.cpp b/third_party/gopher-orch/examples/sdk/client_example_json_list_tool.cpp new file mode 100644 index 00000000..63d4bffd --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/client_example_json_list_tool.cpp @@ -0,0 +1,154 @@ +/** + * @file client_example_json_list_tool.cpp + * @brief Simple MCP client example that lists tools from MCP servers + * + * This example demonstrates: + * - Loading MCP server configuration from JSON + * - Connecting to MCP servers via HTTP/SSE transport + * - Fetching and displaying available tools + */ + +#include +#include +#include +#include + +#include "mcp/event/libevent_dispatcher.h" +#include "gopher/orch/agent/tools_fetcher.h" +#include "gopher/orch/agent/tool_registry.h" + +using namespace gopher::orch; +using namespace gopher::orch::agent; + +int main() { + std::cout << "=== MCP Server Tools Listing Example ===" << std::endl; + std::cout << std::endl; + + // Create event dispatcher + auto dispatcher = std::make_unique("list_tools_example"); + + // MCP server configuration JSON + // Note: For HTTPS servers, the URL should start with https:// + // The client will automatically use SSL/TLS for HTTPS connections + std::string serverJson = R"({ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "gopher-auth-server", + "transport": "http_sse", + "config": { + "url": "https://gmail-mcp-434541420901175298.mcp-test.gopher.security/sse", + "headers": {} + }, + "connectTimeout": 10000, + "requestTimeout": 60000 + } + ] + } + })"; + + // Create ToolsFetcher to load configuration + auto tools_fetcher = std::make_unique(); + + std::cout << "Loading MCP server configuration..." << std::endl; + + bool load_complete = false; + bool load_success = false; + + // Load from JSON configuration + tools_fetcher->loadFromJson(serverJson, *dispatcher, + [&load_complete, &load_success](VoidResult result) { + load_complete = true; + if (mcp::holds_alternative(result)) { + load_success = true; + std::cout << "Configuration loaded successfully!" << std::endl; + } else { + std::cerr << "Failed to load configuration: " + << mcp::get(result).message << std::endl; + } + }); + + // Wait for loading to complete with timeout + auto load_start = std::chrono::steady_clock::now(); + auto load_timeout = std::chrono::seconds(15); + + while (!load_complete) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + if (std::chrono::steady_clock::now() - load_start > load_timeout) { + std::cerr << "Configuration loading timed out after 15 seconds." << std::endl; + std::cerr << "This usually means the MCP server is not responding." << std::endl; + return 1; + } + } + + if (!load_success) { + std::cerr << "\nConfiguration failed. Possible causes:" << std::endl; + std::cerr << "1. MCP server not running at the configured URL" << std::endl; + std::cerr << "2. Network/firewall issues" << std::endl; + std::cerr << "3. Invalid server configuration" << std::endl; + return 1; + } + + // Allow async operations to complete + for (int i = 0; i < 20; i++) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + // Get the tool registry + auto registry = tools_fetcher->getRegistry(); + if (!registry) { + std::cerr << "Failed to get ToolRegistry!" << std::endl; + return 1; + } + + // Display tool count + size_t tool_count = registry->toolCount(); + std::cout << std::endl; + std::cout << "========================================" << std::endl; + std::cout << "Discovered " << tool_count << " tool(s)" << std::endl; + std::cout << "========================================" << std::endl; + + if (tool_count == 0) { + std::cout << "No tools discovered from the MCP server." << std::endl; + return 0; + } + + // Get and display all tool specifications + auto tool_specs = registry->getToolSpecs(); + + std::cout << std::endl; + for (size_t i = 0; i < tool_specs.size(); i++) { + const auto& spec = tool_specs[i]; + std::cout << "[" << (i + 1) << "] " << spec.name << std::endl; + std::cout << " Description: " << spec.description << std::endl; + std::cout << " Parameters: " << spec.parameters.toString() << std::endl; + std::cout << std::endl; + } + + std::cout << "========================================" << std::endl; + std::cout << "=== Tool Listing Complete ===" << std::endl; + + // Shutdown the tools fetcher to close SSE connections + // SSE connections are long-lived and must be explicitly closed + bool shutdown_complete = false; + tools_fetcher->shutdown(*dispatcher, [&shutdown_complete]() { + shutdown_complete = true; + }); + + // Wait for shutdown to complete + while (!shutdown_complete) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + std::cout << "=== Shutdown Complete ===" << std::endl; + return 0; +} diff --git a/third_party/gopher-orch/examples/sdk/client_example_json_list_tool_agent.cpp b/third_party/gopher-orch/examples/sdk/client_example_json_list_tool_agent.cpp new file mode 100644 index 00000000..fed3ed66 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/client_example_json_list_tool_agent.cpp @@ -0,0 +1,104 @@ +/** + * @file client_example_json_list_tool_agent.cpp + * @brief Example demonstrating ReActAgent creation and tool listing + * + * This example demonstrates: + * - Creating a ReActAgent using JSON server configuration + * - Accessing the agent's tool registry + * - Displaying available tools and their specifications + */ + +#include +#include +#include + +#include "gopher/orch/agent/agent.h" +#include "gopher/orch/agent/tool_registry.h" + +using namespace gopher::orch::agent; + +int main() { + std::cout << "=== ReActAgent Tool Listing Example ===" << std::endl; + std::cout << std::endl; + + // Configuration + std::string provider = "AnthropicProvider"; + std::string model = "claude-3-haiku-20240307"; + std::string serverJson = R"({ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "gopher-auth-server", + "transport": "http_sse", + "config": { + "url": "https://gmail-mcp-434541420901175298.mcp-test.gopher.security/sse", + "headers": {} + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + } + ] + } + })"; + + std::cout << "Provider: " << provider << std::endl; + std::cout << "Model: " << model << std::endl; + std::cout << std::endl; + + std::cout << "Creating ReActAgent (loading tools from MCP server)..." << std::endl; + + // Create agent - tools are loaded immediately + auto agent = ReActAgent::createByJson(provider, model, serverJson); + + if (!agent) { + std::cerr << "Error: Failed to create agent" << std::endl; + std::cerr << "Possible causes:" << std::endl; + std::cerr << "1. MCP server not running at the configured URL" << std::endl; + std::cerr << "2. Network/firewall issues" << std::endl; + std::cerr << "3. Invalid server configuration" << std::endl; + return 1; + } + + std::cout << "Agent created successfully!" << std::endl; + std::cout << std::endl; + + // Get the tool registry from the agent + auto registry = agent->tools(); + + if (!registry) { + std::cerr << "Error: Failed to get tool registry from agent" << std::endl; + return 1; + } + + // Display tool count + size_t tool_count = registry->toolCount(); + std::cout << "========================================" << std::endl; + std::cout << "Discovered " << tool_count << " tool(s)" << std::endl; + std::cout << "========================================" << std::endl; + + if (tool_count == 0) { + std::cout << "No tools discovered from the MCP server." << std::endl; + } else { + // Get and display all tool specifications + auto tool_specs = registry->getToolSpecs(); + + std::cout << std::endl; + for (size_t i = 0; i < tool_specs.size(); i++) { + const auto& spec = tool_specs[i]; + std::cout << "[" << (i + 1) << "] " << spec.name << std::endl; + std::cout << " Description: " << spec.description << std::endl; + std::cout << " Parameters: " << spec.parameters.toString() << std::endl; + std::cout << std::endl; + } + } + + std::cout << "========================================" << std::endl; + std::cout << "=== Tool Listing Complete ===" << std::endl; + + return 0; +} diff --git a/third_party/gopher-orch/examples/sdk/gateway_server_example.cpp b/third_party/gopher-orch/examples/sdk/gateway_server_example.cpp new file mode 100644 index 00000000..6044341f --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/gateway_server_example.cpp @@ -0,0 +1,78 @@ +/** + * @file gateway_server_example.cpp + * @brief Example demonstrating GatewayServer that aggregates multiple MCP servers + * + * This example creates a GatewayServer that: + * 1. Connects to multiple backend MCP servers + * 2. Discovers their tools + * 3. Exposes all tools via a single MCP server endpoint + * + * Usage: + * ./gateway_server_example + * + * Prerequisites: + * - MCP servers running on localhost:3001 and localhost:3002 + * + * The gateway will start on localhost:3003 and expose all tools + * from both backend servers. + */ + +#include + +#include "gopher/orch/server/gateway_server.h" + +using namespace gopher::orch::server; + +int main(int argc, char* argv[]) { + std::cout << "=== GatewayServer Example ===" << std::endl; + std::cout << std::endl; + + // JSON configuration for backend servers + // This format matches the API response format + std::string serverJson = R"({ + "succeeded": true, + "data": { + "servers": [ + { + "name": "server3001", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3001/mcp" + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + }, + { + "name": "server3002", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3002/mcp" + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + }, + { + "name": "gmail-server", + "transport": "http_sse", + "config": { + "url": "${some-remote-mcp-server-url}" + }, + "connectTimeout": 20000, + "requestTimeout": 30000 + } + ] + } + })"; + + // Create gateway from JSON configuration + auto gateway = GatewayServer::create(serverJson); + + // Check if creation succeeded + if (!gateway->getError().empty()) { + std::cerr << "Failed to create gateway: " << gateway->getError() << std::endl; + return 1; + } + + // Start listening (blocks until Ctrl+C) + return gateway->listen(3003); +} diff --git a/third_party/gopher-orch/examples/sdk/gateway_server_example_test.cpp b/third_party/gopher-orch/examples/sdk/gateway_server_example_test.cpp new file mode 100644 index 00000000..eae7c2e1 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/gateway_server_example_test.cpp @@ -0,0 +1,96 @@ +/** + * @file gateway_server_example_test.cpp + * @brief Test client that connects to the GatewayServer to test tool aggregation + * + * This example connects to a running GatewayServer (port 3003) which aggregates + * tools from multiple backend MCP servers. Use this to test the gateway's + * reconnect-on-demand functionality after keep-alive timeouts. + */ + +#include +#include + +#include "gopher/orch/agent/agent.h" + +using namespace gopher::orch::agent; + +int main(int argc, char* argv[]) { + std::cout << "=== Gateway Server Test Client ===" << std::endl; + std::cout << "This client connects to the GatewayServer at port 3003" << std::endl; + std::cout << "Make sure gateway_server_example is running first!" << std::endl; + std::cout << std::endl; + std::cout << "Usage: " << argv[0] << " [query1] [query2] ..." << std::endl; + std::cout << "Example: " << argv[0] << " \"What is the weather in Tokyo?\"" << std::endl; + std::cout << std::endl; + + std::string provider = "AnthropicProvider"; + std::string model = "claude-3-haiku-20240307"; + + // Connect to the GatewayServer which aggregates tools from multiple backends + std::string serverJson = R"({ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "gopher-gateway-server", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3003/mcp", + "headers": {} + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + } + ] + } + })"; + + // Parse queries from command line arguments + std::vector queries; + if (argc > 1) { + for (int i = 1; i < argc; i++) { + queries.push_back(argv[i]); + } + } else { + // Default queries using tools available through the gateway + queries.push_back("What is the weather in Tokyo?"); + } + + std::cout << "Provider: " << provider << std::endl; + std::cout << "Model: " << model << std::endl; + std::cout << "Gateway URL: http://127.0.0.1:3003/mcp" << std::endl; + std::cout << "Number of queries: " << queries.size() << std::endl; + std::cout << std::endl; + + std::cout << "Creating agent connected to gateway..." << std::endl; + + auto agent = ReActAgent::createByJson(provider, model, serverJson); + if (!agent) { + std::cout << "Error: Failed to create agent" << std::endl; + std::cout << "Make sure gateway_server_example is running on port 3003" << std::endl; + return 1; + } + + std::cout << "Agent created successfully!" << std::endl; + + // Execute all queries + for (size_t i = 0; i < queries.size(); i++) { + std::cout << "\n========================================" << std::endl; + std::cout << "Query " << (i + 1) << ": " << queries[i] << std::endl; + std::cout << "========================================" << std::endl; + + std::string answer = agent->run(queries[i]); + + std::cout << "\nAgent Response:" << std::endl; + std::cout << "----------------------------------------" << std::endl; + std::cout << answer << std::endl; + std::cout << "----------------------------------------" << std::endl; + } + + std::cout << "\n=== Test Complete ===" << std::endl; + return 0; +} diff --git a/third_party/gopher-orch/examples/sdk/test_error_response.cpp b/third_party/gopher-orch/examples/sdk/test_error_response.cpp new file mode 100644 index 00000000..5eae6f82 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/test_error_response.cpp @@ -0,0 +1,93 @@ +/** + * @file test_error_response.cpp + * @brief Test error handling for API response validation + */ + +#include +#include + +#include "gopher/orch/agent/agent.h" + +using namespace gopher::orch::agent; + +int main() { + std::cout << "=== API Error Response Test ===" << std::endl; + + std::string provider = "AnthropicProvider"; + std::string model = "claude-3-haiku-20240307"; + + // Test case 1: API failure with custom error message + std::string failedApiJson = R"({ + "succeeded": false, + "code": 400000001, + "message": "Invalid server configuration provided", + "data": null + })"; + + std::cout << "\nTest 1: API failure response" << std::endl; + auto agent1 = ReActAgent::createByJson(provider, model, failedApiJson); + if (!agent1) { + std::cout << "✅ Agent creation correctly failed for API error response" << std::endl; + } else { + std::string result = agent1->run("test query"); + std::cout << "Expected error: " << result << std::endl; + } + + // Test case 2: API success but non-"success" message + std::string warningApiJson = R"({ + "succeeded": true, + "code": 200000001, + "message": "Server temporarily unavailable", + "data": { + "servers": [] + } + })"; + + std::cout << "\nTest 2: API success with warning message" << std::endl; + auto agent2 = ReActAgent::createByJson(provider, model, warningApiJson); + if (!agent2) { + std::cout << "✅ Agent creation correctly failed for non-success message" << std::endl; + } else { + std::string result = agent2->run("test query"); + std::cout << "Expected error: " << result << std::endl; + } + + // Test case 3: Invalid JSON + std::string invalidJson = R"({ + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + invalid json here + ] + } + })"; + + std::cout << "\nTest 3: Invalid JSON format" << std::endl; + auto agent3 = ReActAgent::createByJson(provider, model, invalidJson); + if (!agent3) { + std::cout << "✅ Agent creation correctly failed for invalid JSON" << std::endl; + } else { + std::string result = agent3->run("test query"); + std::cout << "Expected error: " << result << std::endl; + } + + // Test case 4: Valid legacy format (should work) + std::string legacyJson = R"({ + "name": "test-config", + "mcp_servers": [] + })"; + + std::cout << "\nTest 4: Legacy format (should work)" << std::endl; + auto agent4 = ReActAgent::createByJson(provider, model, legacyJson); + if (agent4) { + std::cout << "✅ Agent creation succeeded for legacy format" << std::endl; + std::string result = agent4->run("test query"); + std::cout << "Result: " << result << std::endl; + } else { + std::cout << "❌ Agent creation unexpectedly failed for legacy format" << std::endl; + } + + return 0; +} \ No newline at end of file diff --git a/third_party/gopher-orch/examples/sdk/typescript/README.md b/third_party/gopher-orch/examples/sdk/typescript/README.md new file mode 100644 index 00000000..d61e8ed3 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/README.md @@ -0,0 +1,109 @@ +# TypeScript Examples for gopher-orch SDK + +This directory contains TypeScript examples that demonstrate how to use the gopher-orch library via FFI (Foreign Function Interface) bindings. + +## Features + +- **Real C++ FFI Integration**: Direct calls to the gopher-orch C++ library +- **Multiple Query Support**: Run multiple queries in sequence +- **Real AI Responses**: Get actual responses from Claude via MCP servers +- **Tool Discovery**: Automatically discovers MCP tools (time, weather, passwords, etc.) +- **Error Handling**: Comprehensive error handling with helpful messages + +## Available Examples + +### 1. JSON Config Example (`client_example_json.ts`) + +Uses a hardcoded JSON configuration with local MCP servers. + +```bash +# Run with default queries +./client_example_json_run.sh + +# Run with custom queries +./client_example_json_run.sh "What time is it in Tokyo?" "Generate a password" +``` + +### 2. API Config Example (`client_example_api.ts`) + +Fetches MCP server configuration from a remote API using an API key. + +```bash +# Run with default queries +./client_example_api_run.sh + +# Run with custom queries +./client_example_api_run.sh "What tools are available?" "What time is it?" +``` + +## Setup + +### Prerequisites + +1. **Build the C++ library**: + ```bash + cd /path/to/gopher-orch + make build + ``` + +2. **Set your API key** (for Anthropic): + ```bash + export ANTHROPIC_API_KEY="your-api-key-here" + ``` + +### Running Examples + +The run scripts handle everything automatically: +- Build the C++ library +- Copy shared libraries +- Build the TypeScript SDK +- Build and run the examples + +```bash +# JSON config example +./client_example_json_run.sh "What time is it in Tokyo?" + +# API config example +./client_example_api_run.sh "What tools are available?" +``` + +## Scripts + +```bash +npm run build # Compile TypeScript to JavaScript +npm run example:json # Run JSON config example +npm run example:api # Run API config example +npm run clean # Remove compiled files +``` + +## Available Tools + +The system automatically discovers these MCP tools: + +| Tool | Description | Example Query | +|------|-------------|---------------| +| `get-time` | Current time in any timezone | "What time is it in Tokyo?" | +| `get-weather` | Current weather for cities | "What's the weather in London?" | +| `generate-password` | Secure password generation | "Generate a 16-character password" | + +## Troubleshooting + +### Common Issues + +1. **"Agent error: invalid x-api-key"** + - Set a valid Anthropic API key: `export ANTHROPIC_API_KEY="sk-..."` + +2. **"No gopher-orch library found"** + - Run one of the build scripts: `./client_example_json_run.sh` + +3. **"Query execution timed out"** + - Check internet connection and try a simpler query + - MCP servers may be slow or unavailable + +4. **Process won't stop with Ctrl+C** + - The run scripts use `exec` to properly forward signals + - If still stuck, use `Ctrl+\` (SIGQUIT) or `kill` the process + +## License + +This project uses the same license as the main gopher-orch project. diff --git a/third_party/gopher-orch/examples/sdk/typescript/client_example_api_run.sh b/third_party/gopher-orch/examples/sdk/typescript/client_example_api_run.sh new file mode 100755 index 00000000..d4405f5b --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/client_example_api_run.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# client_example_api_run.sh +# Build and run the TypeScript API example + +set -e + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="${SCRIPT_DIR}/../../.." +LOCAL_LIB_DIR="${SCRIPT_DIR}/lib" + +echo -e "${BLUE}=== Gopher-Orch TypeScript API Example ===${NC}\n" + +# Check prerequisites +for cmd in cmake node npm; do + if ! command -v "$cmd" &>/dev/null; then + echo -e "${RED}Error: $cmd not found${NC}" + exit 1 + fi +done + +# Build C++ library +echo -e "${YELLOW}>>> Building C++ library${NC}" +cd "${PROJECT_ROOT}" +mkdir -p build && cd build +[ ! -f "Makefile" ] && cmake .. -DCMAKE_BUILD_TYPE=Release +make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) +echo -e "${GREEN}✓ C++ library built${NC}\n" + +# Copy libraries +echo -e "${YELLOW}>>> Copying libraries${NC}" +mkdir -p "${LOCAL_LIB_DIR}" +cp "${PROJECT_ROOT}/build/lib/libgopher-orch."* "${LOCAL_LIB_DIR}/" 2>/dev/null || true +cp "${PROJECT_ROOT}/build/lib/libgopher-mcp."* "${LOCAL_LIB_DIR}/" 2>/dev/null || true +echo -e "${GREEN}✓ Libraries copied${NC}\n" + +# Build TypeScript SDK (always rebuild to use latest code) +echo -e "${YELLOW}>>> Building TypeScript SDK${NC}" +cd "${PROJECT_ROOT}/sdk/typescript" +npm install --silent +npm run build +echo -e "${GREEN}✓ SDK built${NC}\n" + +# Build examples (always rebuild to use latest code) +echo -e "${YELLOW}>>> Building examples${NC}" +cd "${SCRIPT_DIR}" +npm install --silent +npm run build +echo -e "${GREEN}✓ Examples built${NC}\n" + +# Set library path and run +if [[ "$OSTYPE" == "darwin"* ]]; then + export DYLD_LIBRARY_PATH="${LOCAL_LIB_DIR}:${DYLD_LIBRARY_PATH}" +else + export LD_LIBRARY_PATH="${LOCAL_LIB_DIR}:${LD_LIBRARY_PATH}" +fi + +echo -e "${BLUE}=== Running Example ===${NC}\n" +exec node dist/client_example_api.js diff --git a/third_party/gopher-orch/examples/sdk/typescript/client_example_json_run.sh b/third_party/gopher-orch/examples/sdk/typescript/client_example_json_run.sh new file mode 100755 index 00000000..a395a511 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/client_example_json_run.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# client_example_json_run.sh +# Build and run the TypeScript JSON config example + +set -e + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="${SCRIPT_DIR}/../../.." +LOCAL_LIB_DIR="${SCRIPT_DIR}/lib" + +echo -e "${BLUE}=== Gopher-Orch TypeScript JSON Example ===${NC}\n" + +# Check prerequisites +for cmd in cmake node npm; do + if ! command -v "$cmd" &>/dev/null; then + echo -e "${RED}Error: $cmd not found${NC}" + exit 1 + fi +done + +# Build C++ library +echo -e "${YELLOW}>>> Building C++ library${NC}" +cd "${PROJECT_ROOT}" +mkdir -p build && cd build +[ ! -f "Makefile" ] && cmake .. -DCMAKE_BUILD_TYPE=Release +make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) +echo -e "${GREEN}✓ C++ library built${NC}\n" + +# Copy libraries +echo -e "${YELLOW}>>> Copying libraries${NC}" +mkdir -p "${LOCAL_LIB_DIR}" +cp "${PROJECT_ROOT}/build/lib/libgopher-orch."* "${LOCAL_LIB_DIR}/" 2>/dev/null || true +cp "${PROJECT_ROOT}/build/lib/libgopher-mcp."* "${LOCAL_LIB_DIR}/" 2>/dev/null || true +echo -e "${GREEN}✓ Libraries copied${NC}\n" + +# Build TypeScript SDK (always rebuild to use latest code) +echo -e "${YELLOW}>>> Building TypeScript SDK${NC}" +cd "${PROJECT_ROOT}/sdk/typescript" +npm install --silent +npm run build +echo -e "${GREEN}✓ SDK built${NC}\n" + +# Build examples (always rebuild to use latest code) +echo -e "${YELLOW}>>> Building examples${NC}" +cd "${SCRIPT_DIR}" +npm install --silent +npm run build +echo -e "${GREEN}✓ Examples built${NC}\n" + +# Set library path and run +if [[ "$OSTYPE" == "darwin"* ]]; then + export DYLD_LIBRARY_PATH="${LOCAL_LIB_DIR}:${DYLD_LIBRARY_PATH}" +else + export LD_LIBRARY_PATH="${LOCAL_LIB_DIR}:${LD_LIBRARY_PATH}" +fi + +echo -e "${BLUE}=== Running Example ===${NC}\n" +exec node dist/client_example_json.js diff --git a/third_party/gopher-orch/examples/sdk/typescript/package-lock.json b/third_party/gopher-orch/examples/sdk/typescript/package-lock.json new file mode 100644 index 00000000..c1cc01e9 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/package-lock.json @@ -0,0 +1,68 @@ +{ + "name": "gopher-orch-examples", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gopher-orch-examples", + "version": "1.0.0", + "dependencies": { + "gopher-orch-sdk": "file:../../../sdk/typescript" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "typescript": "^5.0.0" + } + }, + "../../../sdk/typescript": { + "name": "gopher-orch-sdk", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "koffi": "^2.8.0" + }, + "devDependencies": { + "@types/jest": "^29.0.0", + "@types/node": "^18.0.0", + "jest": "^29.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/gopher-orch-sdk": { + "resolved": "../../../sdk/typescript", + "link": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/third_party/gopher-orch/examples/sdk/typescript/package.json b/third_party/gopher-orch/examples/sdk/typescript/package.json new file mode 100644 index 00000000..849f3082 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/package.json @@ -0,0 +1,20 @@ +{ + "name": "gopher-orch-examples", + "version": "1.0.0", + "description": "TypeScript examples for gopher-orch SDK", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "example:json": "node dist/client_example_json.js", + "example:api": "node dist/client_example_api.js", + "clean": "rm -rf dist" + }, + "dependencies": { + "gopher-orch-sdk": "file:../../../sdk/typescript" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "typescript": "^5.0.0" + } +} diff --git a/third_party/gopher-orch/examples/sdk/typescript/src/client_example_api.ts b/third_party/gopher-orch/examples/sdk/typescript/src/client_example_api.ts new file mode 100644 index 00000000..b836bed3 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/src/client_example_api.ts @@ -0,0 +1,33 @@ +/** + * @file client_example_api.ts + * @brief TypeScript example using remote API for server configuration + * + * Demonstrates the clean GopherAgent API for creating agents + * that fetch server configurations from a remote API. + */ +import { GopherAgent } from 'gopher-orch-sdk'; + +async function main(): Promise { + // Create agent with API key (fetches server config from remote API) + // Note: ANTHROPIC_API_KEY is read from environment by C++ layer + const provider = 'AnthropicProvider'; + const model = 'claude-3-haiku-20240307'; + const apiKey = 'sk_xkmdfiw3jfndeaypegwb'; + const agent = GopherAgent.create({ provider, model, apiKey }); + console.log('GopherAgent created!'); + + const question = 'What time is it in London?'; + console.log(`Question: ${question}`); + const answer = agent.run(question); + console.log('Answer:'); + console.log(answer); + + // Cleanup (optional - happens automatically on exit) + agent.dispose(); +} + +// Run +main().catch(error => { + console.error('Error:', error.message); + process.exit(1); +}); diff --git a/third_party/gopher-orch/examples/sdk/typescript/src/client_example_json.ts b/third_party/gopher-orch/examples/sdk/typescript/src/client_example_json.ts new file mode 100644 index 00000000..7571e417 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/src/client_example_json.ts @@ -0,0 +1,106 @@ +/** + * @file client_example_json.ts + * @brief TypeScript example using JSON server configuration + * + * Demonstrates the clean GopherAgent API for creating agents + * with hardcoded JSON server configurations. + */ + +import { GopherAgent } from 'gopher-orch-sdk'; + +// Server configuration JSON +const serverConfig = JSON.stringify({ + succeeded: true, + code: 200000000, + message: "success", + data: { + servers: [ + { + version: "2025-01-09", + serverId: "1877234567890123456", + name: "gopher-auth-server", + transport: "http_sse", + config: { + url: "http://127.0.0.1:3001/rpc", + headers: {} + }, + connectTimeout: 5000, + requestTimeout: 30000 + }, + { + version: "2025-01-09", + serverId: "1877234567890123457", + name: "gopher-auth-server2", + transport: "http_sse", + config: { + url: "http://127.0.0.1:3002/rpc", + headers: {} + }, + connectTimeout: 5000, + requestTimeout: 30000 + } + ] + } +}); + +async function main(): Promise { + console.log('=== Simple Agent Example ==='); + console.log('Usage: node client_example_json.js [query1] [query2] ...'); + console.log('Default queries if none provided:'); + console.log(' 1. What time is it in Tokyo?'); + console.log(' 2. Generate a 12-character password\n'); + + // Initialize the library + GopherAgent.init(); + + // Parse queries from command line arguments + const args = process.argv.slice(2); + const queries = args.length > 0 ? args : [ + 'What time is it in Tokyo?', + 'Generate a 12-character password' + ]; + + const provider = 'AnthropicProvider'; + const model = 'claude-3-haiku-20240307'; + + console.log(`Provider: ${provider}`); + console.log(`Model: ${model}`); + console.log(`Number of queries: ${queries.length}`); + console.log('Creating agent...'); + + try { + // Create agent with JSON server configuration + const agent = GopherAgent.create({ + provider, + model, + serverConfig + }); + + console.log('Agent created successfully!\n'); + + // Execute all queries + for (let i = 0; i < queries.length; i++) { + console.log(`Query ${i + 1}: ${queries[i]}`); + + const answer = agent.run(queries[i]); + + console.log(`\nAgent Response ${i + 1}:`); + console.log('--------------------------------'); + console.log(answer); + console.log('--------------------------------\n'); + } + + // Cleanup (optional - happens automatically on exit) + agent.dispose(); + + } catch (error: any) { + console.error('Error:', error.message); + process.exit(1); + } +} + +// Run +main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/third_party/gopher-orch/examples/sdk/typescript/tsconfig.json b/third_party/gopher-orch/examples/sdk/typescript/tsconfig.json new file mode 100644 index 00000000..50f44ac8 --- /dev/null +++ b/third_party/gopher-orch/examples/sdk/typescript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/third_party/gopher-orch/examples/simple_agent/README.md b/third_party/gopher-orch/examples/simple_agent/README.md new file mode 100644 index 00000000..eef8cd0c --- /dev/null +++ b/third_party/gopher-orch/examples/simple_agent/README.md @@ -0,0 +1,73 @@ +# Simple ReAct Agent Example + +A basic AI agent that uses tools to answer questions using the ReAct (Reasoning + Acting) pattern. + +## What This Example Shows + +- Creating an LLM provider (OpenAI) +- Registering tools (calculator, weather, search) +- Building an AgentRunnable +- Observing agent steps with callbacks +- Running the agent to completion + +## Running + +```bash +# Build +cd build +make simple_agent + +# Run (requires OpenAI API key) +OPENAI_API_KEY=sk-... ./bin/simple_agent + +# Custom query +OPENAI_API_KEY=sk-... ./bin/simple_agent "What's 100/4?" +``` + +## Expected Output + +``` +Query: What's 10*5 and what's the weather in Tokyo? +---------------------------------------- + +[Step 1] Calling tools: calculator get_weather + +[Step 2] Response ready + +======================================== +Final Response: +The result of 10*5 is 50, and the weather in Tokyo is sunny with a +temperature of 72°F and 45% humidity. +---------------------------------------- +Iterations: 2 +Total tokens: 256 +``` + +## Code Walkthrough + +### 1. Create Provider +```cpp +auto provider = makeOpenAIProvider(api_key, "gpt-4"); +``` + +### 2. Register Tools +```cpp +auto registry = makeToolRegistry(); +registry->addSyncTool("calculator", ...); +registry->addTool("get_weather", ...); // async +``` + +### 3. Create Agent +```cpp +auto agent = makeAgentRunnable(provider, registry, config); +``` + +### 4. Run +```cpp +agent->invoke(query, config, dispatcher, callback); +``` + +## See Also + +- [Agent Framework](../../docs/Agent.md) +- [Tool Registry](../../docs/ToolRegistry.md) diff --git a/third_party/gopher-orch/examples/simple_agent/main.cc b/third_party/gopher-orch/examples/simple_agent/main.cc new file mode 100644 index 00000000..5fbbc453 --- /dev/null +++ b/third_party/gopher-orch/examples/simple_agent/main.cc @@ -0,0 +1,165 @@ +// Simple ReAct Agent Example +// +// Demonstrates a basic AI agent that uses tools to answer questions. +// The agent reasons about which tools to use and iterates until done. + +#include + +#include "gopher/orch/orch.h" + +using namespace gopher::orch; +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +int main(int argc, char* argv[]) { + // Check for API key + const char* api_key = std::getenv("OPENAI_API_KEY"); + if (!api_key) { + std::cerr << "Error: OPENAI_API_KEY environment variable not set\n"; + std::cerr << "Usage: OPENAI_API_KEY=sk-... ./simple_agent\n"; + return 1; + } + + // Create event dispatcher + auto dispatcher = mcp::event::createLibeventDispatcher(); + + // ========================================================================= + // Step 1: Create LLM Provider + // ========================================================================= + auto provider = makeOpenAIProvider(api_key, "gpt-4"); + + // ========================================================================= + // Step 2: Create Tool Registry with tools + // ========================================================================= + auto registry = makeToolRegistry(); + + // Calculator tool - synchronous + registry->addSyncTool( + "calculator", + "Perform mathematical calculations. Input: {\"expression\": \"2+2\"}", + JsonValue::object({{"expression", "string"}}), + [](const JsonValue& args) -> Result { + auto expr = args["expression"].getString(); + + // Simple expression evaluator (demo only) + double result = 0; + if (expr == "2+2") + result = 4; + else if (expr == "10*5") + result = 50; + else if (expr == "100/4") + result = 25; + else { + return makeOrchError(OrchError::INVALID_ARGUMENT, + "Cannot evaluate: " + expr); + } + + JsonValue response = JsonValue::object(); + response["result"] = result; + return makeSuccess(std::move(response)); + }); + + // Weather tool - async (simulated) + registry->addTool( + "get_weather", + "Get current weather for a city. Input: {\"city\": \"Tokyo\"}", + JsonValue::object({{"city", "string"}}), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + auto city = args["city"].getString(); + + // Simulate async API call + d.post([city, cb = std::move(cb)]() { + JsonValue weather = JsonValue::object(); + weather["city"] = city; + weather["temperature"] = 72; + weather["condition"] = "sunny"; + weather["humidity"] = 45; + cb(makeSuccess(std::move(weather))); + }); + }); + + // Search tool - async (simulated) + registry->addTool( + "search", "Search the web for information. Input: {\"query\": \"...\"}", + JsonValue::object({{"query", "string"}}), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + auto query = args["query"].getString(); + + d.post([query, cb = std::move(cb)]() { + JsonValue results = JsonValue::object(); + results["query"] = query; + results["results"] = JsonValue::array({ + JsonValue("Result 1: " + query + " - relevant information..."), + JsonValue("Result 2: More details about " + query), + }); + cb(makeSuccess(std::move(results))); + }); + }); + + // ========================================================================= + // Step 3: Create Agent + // ========================================================================= + auto agent = makeAgentRunnable( + provider, registry, + AgentConfig("gpt-4") + .withSystemPrompt( + "You are a helpful assistant with access to tools. " + "Use the calculator for math, get_weather for weather info, " + "and search for general questions. " + "Always explain your reasoning.") + .withMaxIterations(5)); + + // Optional: Set step callback for observability + agent->setStepCallback([](const AgentStep& step) { + std::cout << "\n[Step " << step.step_number << "] "; + if (step.llm_message.hasToolCalls()) { + std::cout << "Calling tools: "; + for (const auto& call : *step.llm_message.tool_calls) { + std::cout << call.name << " "; + } + } else { + std::cout << "Response ready"; + } + std::cout << std::endl; + }); + + // ========================================================================= + // Step 4: Run Agent with a query + // ========================================================================= + std::string query = "What's 10*5 and what's the weather in Tokyo?"; + if (argc > 1) { + query = argv[1]; + } + + std::cout << "Query: " << query << "\n"; + std::cout << "----------------------------------------\n"; + + bool done = false; + agent->invoke( + JsonValue(query), RunnableConfig(), *dispatcher, + [&done](Result result) { + if (mcp::holds_alternative(result)) { + std::cerr << "Error: " << mcp::get(result).message << "\n"; + } else { + auto& output = mcp::get(result); + std::cout << "\n========================================\n"; + std::cout << "Final Response:\n"; + std::cout << output["response"].getString() << "\n"; + std::cout << "----------------------------------------\n"; + std::cout << "Iterations: " << output["iterations"].getInt() << "\n"; + if (output.contains("usage")) { + std::cout << "Total tokens: " + << output["usage"]["total_tokens"].getInt() << "\n"; + } + } + done = true; + }); + + // Run event loop until done + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + + return 0; +} diff --git a/third_party/gopher-orch/examples/workflow/README.md b/third_party/gopher-orch/examples/workflow/README.md new file mode 100644 index 00000000..b59c7e17 --- /dev/null +++ b/third_party/gopher-orch/examples/workflow/README.md @@ -0,0 +1,151 @@ +# StateGraph Workflow Example + +A document processing workflow demonstrating StateGraph with conditional branching. + +## What This Example Shows + +- Building a StateGraph with multiple nodes +- State merging with reducer functions +- Conditional edge routing +- Processing multiple documents through the workflow +- LangGraph-style graph compilation + +## Running + +```bash +# Build +cd build +make workflow + +# Run +./bin/workflow +``` + +## Expected Output + +``` +======================================== +Document 1: +"This API function returns a JSON response with the user data." +---------------------------------------- +Classification: technical +Word count: 11 +Summary: Technical document summary: This API function returns a JSON response... +Keywords: technical, documentation, API + +======================================== +Document 2: +"This agreement constitutes the entire contract between parties." +---------------------------------------- +Classification: legal +Word count: 8 +Summary: Legal document summary: This agreement constitutes the entire contract... +Keywords: legal, contract, agreement +*** Flagged for review *** + +======================================== +Document 3: +"The weather today is sunny with a high of 75 degrees." +---------------------------------------- +Classification: general +Word count: 11 +Summary: General document summary: The weather today is sunny with a high of 75... +Keywords: general, document + +======================================== +All documents processed. +``` + +## Workflow Structure + +``` +START -> count_words -> classify -> [conditional branch] + | + +-----------------+------------------+ + | | | + technical legal general + | | | + summarize_tech summarize_legal summarize_general + | | | + +-----------------+------------------+ + | + finalize -> END +``` + +## Code Walkthrough + +### 1. Define State Structure +```cpp +struct DocumentState { + std::string content; + std::string classification; + std::string summary; + std::vector keywords; + bool needs_review = false; + int word_count = 0; + + static DocumentState merge(const DocumentState& base, + const DocumentState& update); +}; +``` + +### 2. Define Node Functions +```cpp +DocumentState classifyDocument(const DocumentState& state, Dispatcher& d) { + DocumentState update; + // Classification logic... + update.classification = "technical"; + return update; +} +``` + +### 3. Define Router Function +```cpp +std::string routeByClassification(const DocumentState& state) { + if (state.classification == "technical") { + return "summarize_technical"; + } else if (state.classification == "legal") { + return "summarize_legal"; + } + return "summarize_general"; +} +``` + +### 4. Build Graph +```cpp +auto graph = StateGraphBuilder() + .addNode("classify", classifyDocument) + .addNode("summarize_technical", summarizeTechnical) + // ...more nodes... + .addEdge(START, "classify") + .addConditionalEdge("classify", routeByClassification, { + {"summarize_technical", "summarize_technical"}, + {"summarize_legal", "summarize_legal"}, + {"summarize_general", "summarize_general"} + }) + .compile(); +``` + +### 5. Execute Workflow +```cpp +DocumentState initial; +initial.content = "Document content..."; + +graph->invoke(initial, config, dispatcher, [](Result result) { + const auto& state = mcp::get(result); + std::cout << "Classification: " << state.classification << "\n"; +}); +``` + +## Key Concepts + +- **State**: Immutable data structure passed between nodes +- **Nodes**: Functions that transform state +- **Edges**: Define execution flow between nodes +- **Conditional Edges**: Route based on state values +- **Reducer**: Merges partial state updates + +## See Also + +- [StateGraph Guide](../../docs/StateGraph.md) +- [Runnable Interface](../../docs/Runnable.md) diff --git a/third_party/gopher-orch/examples/workflow/main.cc b/third_party/gopher-orch/examples/workflow/main.cc new file mode 100644 index 00000000..2531f119 --- /dev/null +++ b/third_party/gopher-orch/examples/workflow/main.cc @@ -0,0 +1,230 @@ +// StateGraph Workflow Example +// +// Demonstrates a document processing workflow using StateGraph. +// Shows conditional branching, node execution, and state management. + +#include +#include + +#include "gopher/orch/orch.h" + +using namespace gopher::orch; +using namespace gopher::orch::graph; +using namespace gopher::orch::core; + +// Document processing state +struct DocumentState { + std::string content; + std::string classification; // "technical", "legal", "general" + std::string summary; + std::vector keywords; + bool needs_review = false; + int word_count = 0; + + // Merge function for state updates + static DocumentState merge(const DocumentState& base, + const DocumentState& update) { + DocumentState result = base; + if (!update.content.empty()) + result.content = update.content; + if (!update.classification.empty()) + result.classification = update.classification; + if (!update.summary.empty()) + result.summary = update.summary; + if (!update.keywords.empty()) + result.keywords = update.keywords; + if (update.needs_review) + result.needs_review = update.needs_review; + if (update.word_count > 0) + result.word_count = update.word_count; + return result; + } +}; + +// Count words in document +DocumentState countWords(const DocumentState& state, Dispatcher& d) { + DocumentState update; + int count = 0; + bool in_word = false; + for (char c : state.content) { + if (std::isspace(c)) { + in_word = false; + } else if (!in_word) { + in_word = true; + count++; + } + } + update.word_count = count; + return update; +} + +// Classify document based on content +DocumentState classifyDocument(const DocumentState& state, Dispatcher& d) { + DocumentState update; + + // Simple keyword-based classification + const std::string& content = state.content; + if (content.find("API") != std::string::npos || + content.find("function") != std::string::npos || + content.find("code") != std::string::npos) { + update.classification = "technical"; + } else if (content.find("agreement") != std::string::npos || + content.find("contract") != std::string::npos || + content.find("liability") != std::string::npos) { + update.classification = "legal"; + update.needs_review = true; // Legal docs need review + } else { + update.classification = "general"; + } + + return update; +} + +// Generate summary for technical documents +DocumentState summarizeTechnical(const DocumentState& state, Dispatcher& d) { + DocumentState update; + update.summary = + "Technical document summary: " + + state.content.substr(0, std::min(size_t(50), state.content.size())) + + "..."; + update.keywords = {"technical", "documentation", "API"}; + return update; +} + +// Generate summary for legal documents +DocumentState summarizeLegal(const DocumentState& state, Dispatcher& d) { + DocumentState update; + update.summary = + "Legal document summary: " + + state.content.substr(0, std::min(size_t(50), state.content.size())) + + "..."; + update.keywords = {"legal", "contract", "agreement"}; + return update; +} + +// Generate summary for general documents +DocumentState summarizeGeneral(const DocumentState& state, Dispatcher& d) { + DocumentState update; + update.summary = + "General document summary: " + + state.content.substr(0, std::min(size_t(50), state.content.size())) + + "..."; + update.keywords = {"general", "document"}; + return update; +} + +// Finalize processing +DocumentState finalize(const DocumentState& state, Dispatcher& d) { + // No state changes, just a pass-through node + return DocumentState(); +} + +// Router function for conditional branching +std::string routeByClassification(const DocumentState& state) { + if (state.classification == "technical") { + return "summarize_technical"; + } else if (state.classification == "legal") { + return "summarize_legal"; + } else { + return "summarize_general"; + } +} + +int main() { + auto dispatcher = mcp::event::createLibeventDispatcher(); + + // ========================================================================= + // Build StateGraph for document processing + // ========================================================================= + // + // Workflow structure: + // START -> count_words -> classify -> [conditional branch] + // | + // +-----------------+------------------+ + // | | | + // technical legal general + // | | | + // summarize_tech summarize_legal summarize_general + // | | | + // +-----------------+------------------+ + // | + // finalize -> END + + auto graph = + StateGraphBuilder() + .addNode("count_words", countWords) + .addNode("classify", classifyDocument) + .addNode("summarize_technical", summarizeTechnical) + .addNode("summarize_legal", summarizeLegal) + .addNode("summarize_general", summarizeGeneral) + .addNode("finalize", finalize) + // Define edges + .addEdge(START, "count_words") + .addEdge("count_words", "classify") + // Conditional routing based on classification + .addConditionalEdge("classify", routeByClassification, + {{"summarize_technical", "summarize_technical"}, + {"summarize_legal", "summarize_legal"}, + {"summarize_general", "summarize_general"}}) + // All summarization nodes lead to finalize + .addEdge("summarize_technical", "finalize") + .addEdge("summarize_legal", "finalize") + .addEdge("summarize_general", "finalize") + .addEdge("finalize", END) + .compile(); + + // ========================================================================= + // Process sample documents + // ========================================================================= + + std::vector documents = { + "This API function returns a JSON response with the user data.", + "This agreement constitutes the entire contract between parties.", + "The weather today is sunny with a high of 75 degrees.", + }; + + for (size_t i = 0; i < documents.size(); i++) { + std::cout << "\n========================================\n"; + std::cout << "Document " << (i + 1) << ":\n"; + std::cout << "\"" << documents[i] << "\"\n"; + std::cout << "----------------------------------------\n"; + + // Create initial state + DocumentState initial; + initial.content = documents[i]; + + bool done = false; + graph->invoke( + initial, RunnableConfig(), *dispatcher, + [&done](Result result) { + if (mcp::holds_alternative(result)) { + std::cerr << "Error: " << mcp::get(result).message << "\n"; + } else { + const auto& state = mcp::get(result); + std::cout << "Classification: " << state.classification << "\n"; + std::cout << "Word count: " << state.word_count << "\n"; + std::cout << "Summary: " << state.summary << "\n"; + std::cout << "Keywords: "; + for (size_t j = 0; j < state.keywords.size(); j++) { + if (j > 0) + std::cout << ", "; + std::cout << state.keywords[j]; + } + std::cout << "\n"; + if (state.needs_review) { + std::cout << "*** Flagged for review ***\n"; + } + } + done = true; + }); + + while (!done) { + dispatcher->run(mcp::event::Dispatcher::RunType::NonBlock); + } + } + + std::cout << "\n========================================\n"; + std::cout << "All documents processed.\n"; + + return 0; +} diff --git a/third_party/gopher-orch/include/gopher/orch/agent/agent.h b/third_party/gopher-orch/include/gopher/orch/agent/agent.h new file mode 100644 index 00000000..3794840a --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/agent.h @@ -0,0 +1,205 @@ +#pragma once + +// Agent - ReAct-style AI agent implementation +// +// Implements the ReAct (Reasoning + Acting) pattern: +// 1. LLM receives user query and available tools +// 2. LLM reasons and decides to either respond or use tools +// 3. If tool calls requested, execute them +// 4. Feed tool results back to LLM +// 5. Repeat until LLM provides final response +// +// Usage: +// auto provider = createOpenAIProvider("sk-..."); +// auto registry = makeToolRegistry(); +// registry->addTool("search", "Search the web", schema, searchFunc); +// +// AgentConfig config("gpt-4o"); +// config.withSystemPrompt("You are a helpful assistant."); +// +// auto agent = ReActAgent::create(provider, registry, config); +// agent->run("What's the weather in Tokyo?", dispatcher, callback); + +#include +#include +#include +#include + +#include "gopher/orch/agent/agent_types.h" +#include "gopher/orch/agent/tool_executor.h" +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/llm/llm_provider.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::llm; + +// Forward declarations +class Agent; +class ToolsFetcher; +using AgentPtr = std::shared_ptr; + +// Agent - Abstract base class for AI agents +class Agent { + public: + virtual ~Agent() = default; + + // Run the agent with a user query + virtual void run(const std::string& query, + Dispatcher& dispatcher, + AgentCallback callback) = 0; + + // Run with additional context messages + virtual void run(const std::string& query, + const std::vector& context, + Dispatcher& dispatcher, + AgentCallback callback) = 0; + + // Cancel a running agent + virtual void cancel() = 0; + + // Get current state + virtual const AgentState& state() const = 0; + + // Check if running + virtual bool isRunning() const = 0; + + // Set step callback for progress monitoring + virtual void setStepCallback(StepCallback callback) = 0; + + // Set tool approval callback + virtual void setToolApprovalCallback(ToolApprovalCallback callback) = 0; +}; + +// ReActAgent - Implementation of ReAct pattern +// +// Thread Safety: +// - run() should be called from dispatcher thread +// - cancel() can be called from any thread +// - Callbacks are invoked in dispatcher thread context +class ReActAgent : public Agent { + public: + using Ptr = std::shared_ptr; + + // Factory methods + static Ptr create(LLMProviderPtr provider, + ToolRegistryPtr tools, + const AgentConfig& config = AgentConfig()); + + static Ptr create(LLMProviderPtr provider, + const AgentConfig& config = AgentConfig()); + + ~ReActAgent() override; + + // Agent interface + void run(const std::string& query, + Dispatcher& dispatcher, + AgentCallback callback) override; + + void run(const std::string& query, + const std::vector& context, + Dispatcher& dispatcher, + AgentCallback callback) override; + + void cancel() override; + + const AgentState& state() const override; + bool isRunning() const override; + + void setStepCallback(StepCallback callback) override; + void setToolApprovalCallback(ToolApprovalCallback callback) override; + + // ReActAgent-specific methods + + // Get the LLM provider + LLMProviderPtr provider() const; + + // Get the tool registry + ToolRegistryPtr tools() const; + + // Get configuration + const AgentConfig& config() const; + + // Update configuration (only when not running) + void setConfig(const AgentConfig& config); + + // Add tools dynamically + void addTool(const std::string& name, + const std::string& description, + const JsonValue& parameters, + ToolFunction function); + + // Simple agent creation and execution + + // Create agent from provider name, model, and server config JSON + // Returns configured agent ready for multiple queries + static Ptr createByJson(const std::string& provider_name, + const std::string& model, + const std::string& server_json_config); + + // Create agent from provider name, model, and API key + // Fetches server config from remote API using the provided API key + static Ptr createByApiKey(const std::string& provider_name, + const std::string& model, + const std::string& api_key); + + // Simple synchronous run method for queries + // Returns the final response string from the agent + std::string run(const std::string& query); + + private: + explicit ReActAgent(LLMProviderPtr provider, + ToolRegistryPtr tools, + const AgentConfig& config); + + // For simple agent creation (createByJson/createByApiKey) + std::string server_json_config_; + bool tools_loaded_ = true; // Default to true for normal creation + + // Keep MCP server connections alive for the lifetime of the agent + // These are only used when agent is created via createByJson/createByApiKey + std::unique_ptr tools_fetcher_; + std::unique_ptr owned_dispatcher_; + + // Helper methods for simple agent usage + bool loadTools(); + std::string runWithLoadedAgent(const std::string& query); + + // Shutdown MCP connections (called by destructor) + void shutdownConnections(); + + // Internal execution methods + void executeLoop(Dispatcher& dispatcher); + void callLLM(Dispatcher& dispatcher); + void handleLLMResponse(const LLMResponse& response, Dispatcher& dispatcher); + void executeToolCalls(const std::vector& calls, + Dispatcher& dispatcher); + void handleToolResults(const std::vector& calls, + const std::vector>& results, + Dispatcher& dispatcher); + void completeRun(AgentStatus status, Dispatcher& dispatcher); + + // Build result from current state + AgentResult buildResult() const; + + class Impl; + std::unique_ptr impl_; +}; + +// Convenience function to create agent +inline AgentPtr makeAgent(LLMProviderPtr provider, + ToolRegistryPtr tools, + const AgentConfig& config = AgentConfig()) { + return ReActAgent::create(provider, tools, config); +} + +inline AgentPtr makeAgent(LLMProviderPtr provider, + const AgentConfig& config = AgentConfig()) { + return ReActAgent::create(provider, config); +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/agent_module.h b/third_party/gopher-orch/include/gopher/orch/agent/agent_module.h new file mode 100644 index 00000000..001c37cc --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/agent_module.h @@ -0,0 +1,70 @@ +#pragma once + +// Agent Module - AI agent framework with ReAct pattern +// +// This module provides: +// - Agent: Abstract interface for AI agents +// - ReActAgent: ReAct pattern implementation (Reasoning + Acting) +// - ToolRegistry: Unified tool management from multiple sources +// - AgentConfig, AgentState, AgentResult: Configuration and state types +// +// Usage: +// #include "gopher/orch/agent/agent_module.h" +// using namespace gopher::orch::agent; +// +// // Create LLM provider +// auto provider = createOpenAIProvider("sk-..."); +// +// // Create tool registry and add tools +// auto registry = makeToolRegistry(); +// registry->addTool("search", "Search the web", schema, +// [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { +// // Search implementation... +// }); +// +// // Create and configure agent +// AgentConfig config("gpt-4o"); +// config.withSystemPrompt("You are a helpful research assistant.") +// .withMaxIterations(10); +// +// auto agent = makeAgent(provider, registry, config); +// +// // Run agent +// agent->run("What's the latest news about AI?", dispatcher, +// [](Result result) { +// if (result.isOk()) { +// std::cout << result.value().response << std::endl; +// } +// }); + +// Core types +#include "gopher/orch/agent/agent_types.h" + +// API configuration +#include "gopher/orch/agent/api_engine.h" + +// Tool definitions and configuration +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/agent/rest_tool_adapter.h" +#include "gopher/orch/agent/tool_definition.h" + +// Tool management +#include "gopher/orch/agent/tool_registry.h" + +// Agent interface and implementations +#include "gopher/orch/agent/agent.h" + +namespace gopher { +namespace orch { +namespace agent { + +// Convenience re-exports +using core::Dispatcher; +using core::Error; +using core::JsonCallback; +using core::JsonValue; +using core::Result; + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/agent_runnable.h b/third_party/gopher-orch/include/gopher/orch/agent/agent_runnable.h new file mode 100644 index 00000000..fffa313b --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/agent_runnable.h @@ -0,0 +1,216 @@ +#pragma once + +// AgentRunnable - Wraps ReAct Agent as a composable Runnable +// +// Makes the ReAct agent pattern composable with other Runnables in pipelines, +// sequences, and graphs. Internally operates as a graph with LLM and Tool +// nodes. +// +// This is the main integration point for agent + runnable composition, +// implementing the wrapper pattern (Option A from design doc). +// +// Usage: +// auto provider = createOpenAIProvider("sk-..."); +// auto registry = makeToolRegistry(); +// registry->addTool("search", "Search", schema, handler); +// +// auto agent = AgentRunnable::create(provider, registry, +// AgentConfig("gpt-4").withSystemPrompt("You are helpful")); +// +// JsonValue input = JsonValue::object(); +// input["query"] = "What is the weather in Tokyo?"; +// +// agent->invoke(input, config, dispatcher, callback); + +#include +#include + +#include "gopher/orch/agent/agent_types.h" +#include "gopher/orch/agent/tool_executor.h" +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/llm/llm_provider.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; +using namespace gopher::orch::llm; + +// Forward declaration +class AgentRunnable; +using AgentRunnablePtr = std::shared_ptr; + +// AgentRunnable - ReAct Agent as a Runnable +// +// Input Schema: +// { +// "query": "What is the weather?", // Required +// "context": [...], // Optional: prior messages +// "config": { // Optional: override config +// "max_iterations": 5, +// "system_prompt": "..." +// } +// } +// +// Alternative inputs (auto-detected): +// - Simple string: "What is the weather?" +// - LangGraph-style: {"messages": [...]} +// +// Output Schema: +// { +// "response": "The weather is sunny.", +// "status": "completed", +// "iterations": 2, +// "messages": [...], +// "usage": {...}, +// "duration_ms": 3500 +// } +class AgentRunnable : public Runnable { + public: + using Ptr = std::shared_ptr; + + // Factory methods + static Ptr create(LLMProviderPtr provider, + ToolExecutorPtr executor, + const AgentConfig& config = AgentConfig()); + + static Ptr create(LLMProviderPtr provider, + ToolRegistryPtr registry, + const AgentConfig& config = AgentConfig()); + + static Ptr create(LLMProviderPtr provider, + const AgentConfig& config = AgentConfig()); + + // Runnable interface + std::string name() const override; + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override; + + // ========================================================================= + // CONFIGURATION + // ========================================================================= + + // Get/set config + const AgentConfig& config() const { return config_; } + void setConfig(const AgentConfig& config) { config_ = config; } + + // Get components + LLMProviderPtr provider() const { return provider_; } + ToolExecutorPtr executor() const { return executor_; } + ToolRegistryPtr registry() const { + return executor_ ? executor_->registry() : nullptr; + } + + // ========================================================================= + // CALLBACKS + // ========================================================================= + + // Called after each step (LLM call + tool executions) + void setStepCallback(StepCallback callback) { + step_callback_ = std::move(callback); + } + + // Called before tool execution for approval + void setToolApprovalCallback(ToolApprovalCallback callback) { + approval_callback_ = std::move(callback); + } + + private: + AgentRunnable(LLMProviderPtr provider, + ToolExecutorPtr executor, + const AgentConfig& config); + + // ========================================================================= + // INPUT PARSING + // ========================================================================= + + struct ParsedInput { + std::string query; + std::vector context; + AgentConfig config; + }; + ParsedInput parseInput(const JsonValue& input) const; + + // ========================================================================= + // AGENT LOOP EXECUTION + // ========================================================================= + + // Execute the ReAct loop + void executeLoop(AgentState& state, + Dispatcher& dispatcher, + Callback callback); + + // Call LLM with current state + void callLLM(AgentState& state, Dispatcher& dispatcher, Callback callback); + + // Handle LLM response (may call tools or complete) + void handleLLMResponse(const LLMResponse& response, + AgentState& state, + Dispatcher& dispatcher, + Callback callback); + + // Execute tool calls + void executeTools(const std::vector& calls, + AgentState& state, + Dispatcher& dispatcher, + Callback callback); + + // Complete the agent run (success or failure) + void completeRun(AgentState& state, Callback callback); + + // ========================================================================= + // OUTPUT BUILDING + // ========================================================================= + + // Build output JSON from final state + JsonValue buildOutput(const AgentState& state) const; + + // ========================================================================= + // HELPERS + // ========================================================================= + + // Build messages array for LLM call + std::vector buildMessages(const AgentState& state) const; + + // Get tool specs for LLM + std::vector getToolSpecs() const; + + // Check if should continue loop + bool shouldContinue(const AgentState& state) const; + + // Record a step + void recordStep(AgentState& state, + const Message& llm_message, + const optional& usage, + std::chrono::milliseconds llm_duration); + + LLMProviderPtr provider_; + ToolExecutorPtr executor_; + AgentConfig config_; + + StepCallback step_callback_; + ToolApprovalCallback approval_callback_; +}; + +// Convenience factory functions +inline AgentRunnablePtr makeAgentRunnable( + LLMProviderPtr provider, + ToolRegistryPtr registry, + const AgentConfig& config = AgentConfig()) { + return AgentRunnable::create(std::move(provider), std::move(registry), + config); +} + +inline AgentRunnablePtr makeAgentRunnable( + LLMProviderPtr provider, const AgentConfig& config = AgentConfig()) { + return AgentRunnable::create(std::move(provider), config); +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/agent_types.h b/third_party/gopher-orch/include/gopher/orch/agent/agent_types.h new file mode 100644 index 00000000..9a63553e --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/agent_types.h @@ -0,0 +1,484 @@ +#pragma once + +// Agent Types - Core types for AI agent implementation +// +// Provides configuration, state, and result types for running +// ReAct-style agents that combine LLM reasoning with tool execution. + +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" +#include "gopher/orch/llm/llm_types.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; +using namespace gopher::orch::llm; + +// ═══════════════════════════════════════════════════════════════════════════ +// AGENT CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════ + +struct AgentConfig { + // LLM configuration + LLMConfig llm_config; + + // System prompt for the agent + std::string system_prompt; + + // Maximum iterations in the ReAct loop (prevents infinite loops) + int max_iterations = 10; + + // Maximum total tokens across all iterations + optional max_total_tokens; + + // Timeout for entire agent run + std::chrono::milliseconds timeout{300000}; // 5 minutes default + + // Tool execution settings + bool parallel_tool_calls = true; // Execute multiple tool calls in parallel + + // Callbacks + bool enable_step_callbacks = true; + + AgentConfig() = default; + + explicit AgentConfig(const std::string& model) : llm_config(model) {} + + AgentConfig& withModel(const std::string& model) { + llm_config.model = model; + return *this; + } + + AgentConfig& withSystemPrompt(const std::string& prompt) { + system_prompt = prompt; + return *this; + } + + AgentConfig& withTemperature(double t) { + llm_config.temperature = t; + return *this; + } + + AgentConfig& withMaxTokens(int tokens) { + llm_config.max_tokens = tokens; + return *this; + } + + AgentConfig& withMaxIterations(int iterations) { + max_iterations = iterations; + return *this; + } + + AgentConfig& withTimeout(std::chrono::milliseconds t) { + timeout = t; + return *this; + } + + AgentConfig& withParallelToolCalls(bool enabled) { + parallel_tool_calls = enabled; + return *this; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// AGENT STATE +// ═══════════════════════════════════════════════════════════════════════════ + +// Current state of agent execution +enum class AgentStatus { + IDLE, // Not started + RUNNING, // Currently executing + COMPLETED, // Finished successfully + FAILED, // Error occurred + CANCELLED, // Cancelled by user + MAX_ITERATIONS_REACHED // Hit iteration limit +}; + +// Convert status to string +inline std::string agentStatusToString(AgentStatus status) { + switch (status) { + case AgentStatus::IDLE: + return "idle"; + case AgentStatus::RUNNING: + return "running"; + case AgentStatus::COMPLETED: + return "completed"; + case AgentStatus::FAILED: + return "failed"; + case AgentStatus::CANCELLED: + return "cancelled"; + case AgentStatus::MAX_ITERATIONS_REACHED: + return "max_iterations_reached"; + default: + return "unknown"; + } +} + +// Record of a single tool execution +struct ToolExecution { + std::string tool_name; + std::string call_id; + JsonValue input; + JsonValue output; + bool success = true; + std::string error_message; + std::chrono::milliseconds duration{0}; +}; + +// Record of a single agent step (one LLM call + tool executions) +struct AgentStep { + int step_number = 0; + + // LLM response for this step + Message llm_message; + optional llm_usage; + + // Tool executions (if any) + std::vector tool_executions; + + // Timing + std::chrono::milliseconds llm_duration{0}; + std::chrono::milliseconds tools_duration{0}; +}; + +// Current state during agent execution +// +// Supports reducer-based state updates for graph-style execution. +// Messages use APPEND semantics (like LangGraph's add_messages), +// other fields use last-write-wins semantics. +struct AgentState { + AgentStatus status = AgentStatus::IDLE; + + // Conversation history (uses APPEND reducer) + std::vector messages; + + // Steps taken (uses APPEND reducer) + std::vector steps; + + // Current iteration (last-write-wins) + int current_iteration = 0; + + // Remaining steps before max iterations (last-write-wins) + int remaining_steps = 10; + + // Token usage (accumulated) + Usage total_usage; + + // Timing + std::chrono::steady_clock::time_point start_time; + std::chrono::milliseconds elapsed{0}; + + // Error info (if failed, last-write-wins) + optional error; + + // Check if agent is still running + bool isRunning() const { return status == AgentStatus::RUNNING; } + + // Check if agent completed successfully + bool isCompleted() const { return status == AgentStatus::COMPLETED; } + + // Get last message content + std::string lastContent() const { + if (messages.empty()) + return ""; + return messages.back().content; + } + + // ========================================================================= + // REDUCER - Merges state updates following LangGraph semantics + // ========================================================================= + + // Reduce (merge) two states. Used by graph execution to combine node outputs. + // - messages: APPEND (new messages are appended to existing) + // - steps: APPEND (new steps are appended) + // - current_iteration: last-write-wins + // - remaining_steps: last-write-wins + // - total_usage: accumulated (tokens are added) + // - status, error: last-write-wins + static AgentState reduce(const AgentState& current, + const AgentState& update) { + AgentState result; + + // APPEND: messages + result.messages = current.messages; + for (const auto& msg : update.messages) { + result.messages.push_back(msg); + } + + // APPEND: steps + result.steps = current.steps; + for (const auto& step : update.steps) { + result.steps.push_back(step); + } + + // LAST-WRITE-WINS: other fields + result.status = update.status; + result.current_iteration = update.current_iteration; + result.remaining_steps = update.remaining_steps; + result.error = update.error; + result.elapsed = update.elapsed; + result.start_time = update.start_time; + + // ACCUMULATE: token usage + result.total_usage.prompt_tokens = + current.total_usage.prompt_tokens + update.total_usage.prompt_tokens; + result.total_usage.completion_tokens = + current.total_usage.completion_tokens + + update.total_usage.completion_tokens; + result.total_usage.total_tokens = + current.total_usage.total_tokens + update.total_usage.total_tokens; + + return result; + } + + // ========================================================================= + // JSON SERIALIZATION - For graph node I/O + // ========================================================================= + + // Convert state to JSON for passing between graph nodes + JsonValue toJson() const { + JsonValue json = JsonValue::object(); + + json["status"] = agentStatusToString(status); + json["current_iteration"] = current_iteration; + json["remaining_steps"] = remaining_steps; + + // Messages array + JsonValue messages_arr = JsonValue::array(); + for (const auto& msg : messages) { + JsonValue msg_json = JsonValue::object(); + msg_json["role"] = roleToString(msg.role); + msg_json["content"] = msg.content; + if (msg.tool_call_id.has_value()) { + msg_json["tool_call_id"] = *msg.tool_call_id; + } + if (msg.hasToolCalls()) { + JsonValue calls_arr = JsonValue::array(); + for (const auto& call : *msg.tool_calls) { + JsonValue call_json = JsonValue::object(); + call_json["id"] = call.id; + call_json["name"] = call.name; + call_json["arguments"] = call.arguments; + calls_arr.push_back(call_json); + } + msg_json["tool_calls"] = calls_arr; + } + messages_arr.push_back(msg_json); + } + json["messages"] = messages_arr; + + // Usage + JsonValue usage_json = JsonValue::object(); + usage_json["prompt_tokens"] = total_usage.prompt_tokens; + usage_json["completion_tokens"] = total_usage.completion_tokens; + usage_json["total_tokens"] = total_usage.total_tokens; + json["usage"] = usage_json; + + // Error if present + if (error.has_value()) { + JsonValue err_json = JsonValue::object(); + err_json["code"] = error->code; + err_json["message"] = error->message; + json["error"] = err_json; + } + + return json; + } + + // Parse state from JSON + static AgentState fromJson(const JsonValue& json) { + AgentState state; + + if (!json.isObject()) { + return state; + } + + // Parse status + if (json.contains("status") && json["status"].isString()) { + std::string status_str = json["status"].getString(); + if (status_str == "idle") + state.status = AgentStatus::IDLE; + else if (status_str == "running") + state.status = AgentStatus::RUNNING; + else if (status_str == "completed") + state.status = AgentStatus::COMPLETED; + else if (status_str == "failed") + state.status = AgentStatus::FAILED; + else if (status_str == "cancelled") + state.status = AgentStatus::CANCELLED; + else if (status_str == "max_iterations_reached") + state.status = AgentStatus::MAX_ITERATIONS_REACHED; + } + + // Parse iteration counts + if (json.contains("current_iteration") && + json["current_iteration"].isNumber()) { + state.current_iteration = json["current_iteration"].getInt(); + } + if (json.contains("remaining_steps") && + json["remaining_steps"].isNumber()) { + state.remaining_steps = json["remaining_steps"].getInt(); + } + + // Parse messages + if (json.contains("messages") && json["messages"].isArray()) { + const auto& msgs_arr = json["messages"]; + for (size_t i = 0; i < msgs_arr.size(); ++i) { + const auto& msg_json = msgs_arr[i]; + if (!msg_json.isObject()) + continue; + + Role role = Role::USER; + if (msg_json.contains("role") && msg_json["role"].isString()) { + role = parseRole(msg_json["role"].getString()); + } + + std::string content; + if (msg_json.contains("content") && msg_json["content"].isString()) { + content = msg_json["content"].getString(); + } + + Message msg(role, content); + + if (msg_json.contains("tool_call_id") && + msg_json["tool_call_id"].isString()) { + msg.tool_call_id = msg_json["tool_call_id"].getString(); + } + + if (msg_json.contains("tool_calls") && + msg_json["tool_calls"].isArray()) { + std::vector calls; + const auto& calls_arr = msg_json["tool_calls"]; + for (size_t j = 0; j < calls_arr.size(); ++j) { + const auto& call_json = calls_arr[j]; + if (!call_json.isObject()) + continue; + ToolCall call; + if (call_json.contains("id") && call_json["id"].isString()) { + call.id = call_json["id"].getString(); + } + if (call_json.contains("name") && call_json["name"].isString()) { + call.name = call_json["name"].getString(); + } + if (call_json.contains("arguments")) { + call.arguments = call_json["arguments"]; + } + calls.push_back(std::move(call)); + } + if (!calls.empty()) { + msg.tool_calls = std::move(calls); + } + } + + state.messages.push_back(std::move(msg)); + } + } + + // Parse usage + if (json.contains("usage") && json["usage"].isObject()) { + const auto& usage_json = json["usage"]; + if (usage_json.contains("prompt_tokens") && + usage_json["prompt_tokens"].isNumber()) { + state.total_usage.prompt_tokens = usage_json["prompt_tokens"].getInt(); + } + if (usage_json.contains("completion_tokens") && + usage_json["completion_tokens"].isNumber()) { + state.total_usage.completion_tokens = + usage_json["completion_tokens"].getInt(); + } + if (usage_json.contains("total_tokens") && + usage_json["total_tokens"].isNumber()) { + state.total_usage.total_tokens = usage_json["total_tokens"].getInt(); + } + } + + // Parse error + if (json.contains("error") && json["error"].isObject()) { + const auto& err_json = json["error"]; + int code = 0; + std::string message; + if (err_json.contains("code") && err_json["code"].isNumber()) { + code = err_json["code"].getInt(); + } + if (err_json.contains("message") && err_json["message"].isString()) { + message = err_json["message"].getString(); + } + state.error = Error(code, message); + } + + return state; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// AGENT RESULT +// ═══════════════════════════════════════════════════════════════════════════ + +struct AgentResult { + AgentStatus status = AgentStatus::IDLE; + + // Final response from the agent + std::string response; + + // Full conversation history + std::vector messages; + + // All steps taken + std::vector steps; + + // Total usage across all LLM calls + Usage total_usage; + + // Total time taken + std::chrono::milliseconds duration{0}; + + // Error info (if failed) + optional error; + + // Check if successful + bool isSuccess() const { return status == AgentStatus::COMPLETED; } + + // Get number of iterations + int iterationCount() const { return static_cast(steps.size()); } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// CALLBACKS +// ═══════════════════════════════════════════════════════════════════════════ + +// Called when agent completes +using AgentCallback = std::function)>; + +// Called after each step (for progress monitoring) +using StepCallback = std::function; + +// Called before tool execution (can modify/approve) +using ToolApprovalCallback = std::function; + +// ═══════════════════════════════════════════════════════════════════════════ +// ERROR CODES +// ═══════════════════════════════════════════════════════════════════════════ + +namespace AgentError { +enum : int { + OK = 0, + NO_PROVIDER = -200, + NO_TOOLS = -201, + MAX_ITERATIONS = -202, + TIMEOUT = -203, + TOOL_EXECUTION_FAILED = -204, + LLM_ERROR = -205, + CANCELLED = -206, + UNKNOWN = -299 +}; +} // namespace AgentError + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/api_engine.h b/third_party/gopher-orch/include/gopher/orch/agent/api_engine.h new file mode 100644 index 00000000..3a808cc1 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/api_engine.h @@ -0,0 +1,58 @@ +#pragma once + +// ApiEngine - API configuration and utilities for the Agent system +// +// Provides API endpoint configuration that is determined at build time +// based on CMake options and can be queried at runtime. +// +// Usage: +// std::string api_url = ApiEngine::getApiUrlRoot(); + +#include + +namespace gopher { +namespace orch { +namespace agent { + +// ═══════════════════════════════════════════════════════════════════════════ +// API ENGINE +// ═══════════════════════════════════════════════════════════════════════════ + +class ApiEngine { + public: + // ───────────────────────────────────────────────────────────────────────── + // API Configuration + // ───────────────────────────────────────────────────────────────────────── + + // Get the root URL for the Gopher API + // Returns production URL when BUILD_API_PRODUCT=ON, test URL otherwise + static std::string getApiUrlRoot(); + + // ───────────────────────────────────────────────────────────────────────── + // MCP Server Management + // ───────────────────────────────────────────────────────────────────────── + + // Fetch MCP server configurations from the API + // Makes HTTPS request to getApiUrlRoot() + "/v1/mcp-servers" + // Returns JSON response as string + static std::string fetchMcpServers(const std::string& apiKey); + + private: + ApiEngine() = delete; // Static class - no instances +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// INLINE IMPLEMENTATIONS +// ═══════════════════════════════════════════════════════════════════════════ + +inline std::string ApiEngine::getApiUrlRoot() { +#if BUILD_API_PRODUCT + return "https://api.gopher.security"; +#else + return "https://api-test.gopher.security"; +#endif +} + +} // namespace agent +} // namespace orch +} // namespace gopher \ No newline at end of file diff --git a/third_party/gopher-orch/include/gopher/orch/agent/config_loader.h b/third_party/gopher-orch/include/gopher/orch/agent/config_loader.h new file mode 100644 index 00000000..cc1c2be6 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/config_loader.h @@ -0,0 +1,501 @@ +#pragma once + +// ConfigLoader - Load tool registry configuration from JSON +// +// Supports: +// - JSON file loading +// - Environment variable substitution (${VAR_NAME}) +// - Parsing of RegistryConfig, ToolDefinition, MCPServerDefinition +// +// Usage: +// ConfigLoader loader; +// loader.setEnv("API_KEY", "secret"); +// +// auto config = loader.loadFromFile("tools.json"); +// if (config.isOk()) { +// registry->loadConfig(config.value(), dispatcher, callback); +// } + +#include +#include +#include +#include + +#include "gopher/orch/agent/tool_definition.h" + +namespace gopher { +namespace orch { +namespace agent { + +// ═══════════════════════════════════════════════════════════════════════════ +// CONFIG LOADER +// ═══════════════════════════════════════════════════════════════════════════ + +class ConfigLoader { + public: + ConfigLoader() = default; + + // ───────────────────────────────────────────────────────────────────────── + // Environment Variables + // ───────────────────────────────────────────────────────────────────────── + + // Set environment variable for ${VAR} substitution + void setEnv(const std::string& name, const std::string& value) { + env_vars_[name] = value; + } + + // Set multiple environment variables + void setEnvMap(const std::map& vars) { + for (const auto& kv : vars) { + env_vars_[kv.first] = kv.second; + } + } + + // Load environment from .env file + VoidResult loadEnvFile(const std::string& path); + + // Substitute ${VAR_NAME} in string + std::string substituteEnvVars(const std::string& input) const { + std::string result = input; + std::regex env_pattern("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}"); + std::smatch match; + + while (std::regex_search(result, match, env_pattern)) { + std::string var_name = match[1].str(); + std::string value; + + // Check our env vars first + auto it = env_vars_.find(var_name); + if (it != env_vars_.end()) { + value = it->second; + } else { + // Fall back to system env + const char* env_val = std::getenv(var_name.c_str()); + if (env_val) { + value = env_val; + } + } + + result = result.replace(match.position(), match.length(), value); + } + + return result; + } + + // ───────────────────────────────────────────────────────────────────────── + // JSON Loading + // ───────────────────────────────────────────────────────────────────────── + + // Load from file path + Result loadFromFile(const std::string& path); + + // Load from JSON string + Result loadFromString(const std::string& json_string); + + // Load from JsonValue + Result loadFromJson(const JsonValue& json); + + // ───────────────────────────────────────────────────────────────────────── + // Parsing Helpers + // ───────────────────────────────────────────────────────────────────────── + + // Parse individual definitions + Result parseToolDefinition(const JsonValue& json); + Result parseMCPServerDefinition(const JsonValue& json, bool is_api_response = false); + Result parseAuthPreset(const JsonValue& json); + + private: + // Parse HTTP method from string + HttpMethod parseHttpMethod(const std::string& method) const; + + // Parse transport type from string + MCPServerDefinition::TransportType parseTransportType( + const std::string& transport) const; + + std::map env_vars_; +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// INLINE IMPLEMENTATIONS +// ═══════════════════════════════════════════════════════════════════════════ + +inline HttpMethod ConfigLoader::parseHttpMethod( + const std::string& method) const { + if (method == "GET") + return HttpMethod::GET; + if (method == "POST") + return HttpMethod::POST; + if (method == "PUT") + return HttpMethod::PUT; + if (method == "PATCH") + return HttpMethod::PATCH; + if (method == "DELETE") + return HttpMethod::DELETE_; + if (method == "HEAD") + return HttpMethod::HEAD; + if (method == "OPTIONS") + return HttpMethod::OPTIONS; + return HttpMethod::GET; +} + +inline MCPServerDefinition::TransportType ConfigLoader::parseTransportType( + const std::string& transport) const { + if (transport == "stdio") + return MCPServerDefinition::TransportType::STDIO; + if (transport == "http_sse" || transport == "http-sse" || transport == "sse") + return MCPServerDefinition::TransportType::HTTP_SSE; + if (transport == "websocket" || transport == "ws") + return MCPServerDefinition::TransportType::WEBSOCKET; + return MCPServerDefinition::TransportType::STDIO; +} + +inline Result ConfigLoader::parseAuthPreset(const JsonValue& json) { + AuthPreset auth; + + std::string type = + json.contains("type") ? json["type"].getString() : "bearer"; + if (type == "bearer") { + auth.type = AuthPreset::Type::BEARER; + } else if (type == "api_key" || type == "apikey") { + auth.type = AuthPreset::Type::API_KEY; + } else if (type == "basic") { + auth.type = AuthPreset::Type::BASIC; + } + + auth.value = substituteEnvVars( + json.contains("value") ? json["value"].getString() : ""); + auth.header = + json.contains("header") ? json["header"].getString() : "Authorization"; + + return Result(std::move(auth)); +} + +inline Result ConfigLoader::parseMCPServerDefinition( + const JsonValue& json, bool is_api_response) { + MCPServerDefinition def; + + def.name = json.contains("name") ? json["name"].getString() : ""; + if (def.name.empty()) { + return Result( + Error(-1, "MCP server definition missing 'name'")); + } + + std::string transport = + json.contains("transport") ? json["transport"].getString() : "stdio"; + def.transport = parseTransportType(transport); + + // Parse transport-specific config + switch (def.transport) { + case MCPServerDefinition::TransportType::STDIO: { + const JsonValue* stdio_config = nullptr; + if (is_api_response && json.contains("config")) { + stdio_config = &json["config"]; + } else if (json.contains("stdio")) { + stdio_config = &json["stdio"]; + } + + if (stdio_config) { + MCPServerDefinition::StdioConfig cfg; + cfg.command = substituteEnvVars( + stdio_config->contains("command") ? (*stdio_config)["command"].getString() : ""); + + if (stdio_config->contains("args") && (*stdio_config)["args"].isArray()) { + const auto& args = (*stdio_config)["args"]; + for (size_t i = 0; i < args.size(); ++i) { + cfg.args.push_back(substituteEnvVars(args[i].getString())); + } + } + + if (stdio_config->contains("env") && (*stdio_config)["env"].isObject()) { + for (auto it = (*stdio_config)["env"].begin(); it != (*stdio_config)["env"].end(); ++it) { + auto kv = *it; + cfg.env[kv.first] = substituteEnvVars(kv.second.getString()); + } + } + + cfg.working_directory = stdio_config->contains("working_directory") + ? (*stdio_config)["working_directory"].getString() + : ""; + def.stdio_config = std::move(cfg); + } + break; + } + + case MCPServerDefinition::TransportType::HTTP_SSE: { + const JsonValue* sse_config = nullptr; + if (is_api_response && json.contains("config")) { + // New format: use "config" directly for HTTP_SSE + sse_config = &json["config"]; + } else if (json.contains("http_sse")) { + // Old format: nested under "http_sse" + sse_config = &json["http_sse"]; + } + + if (sse_config) { + MCPServerDefinition::HttpSseConfig cfg; + cfg.url = substituteEnvVars(sse_config->contains("url") ? (*sse_config)["url"].getString() + : ""); + cfg.verify_ssl = + sse_config->contains("verify_ssl") ? (*sse_config)["verify_ssl"].getBool() : true; + + if (sse_config->contains("headers") && (*sse_config)["headers"].isObject()) { + for (auto it = (*sse_config)["headers"].begin(); it != (*sse_config)["headers"].end(); + ++it) { + auto kv = *it; + cfg.headers[kv.first] = substituteEnvVars(kv.second.getString()); + } + } + + def.http_sse_config = std::move(cfg); + } + break; + } + + case MCPServerDefinition::TransportType::WEBSOCKET: { + const JsonValue* ws_config = nullptr; + if (is_api_response && json.contains("config")) { + ws_config = &json["config"]; + } else if (json.contains("websocket")) { + ws_config = &json["websocket"]; + } + + if (ws_config) { + MCPServerDefinition::WebSocketConfig cfg; + cfg.url = + substituteEnvVars(ws_config->contains("url") ? (*ws_config)["url"].getString() : ""); + cfg.verify_ssl = + ws_config->contains("verify_ssl") ? (*ws_config)["verify_ssl"].getBool() : true; + + if (ws_config->contains("headers") && (*ws_config)["headers"].isObject()) { + for (auto it = (*ws_config)["headers"].begin(); it != (*ws_config)["headers"].end(); + ++it) { + auto kv = *it; + cfg.headers[kv.first] = substituteEnvVars(kv.second.getString()); + } + } + + def.websocket_config = std::move(cfg); + } + break; + } + } + + // Parse timeouts - handle both old and new field names + if (is_api_response) { + // New format uses camelCase + if (json.contains("connectTimeout")) { + def.connect_timeout = std::chrono::milliseconds(json["connectTimeout"].getInt()); + } + if (json.contains("requestTimeout")) { + def.request_timeout = std::chrono::milliseconds(json["requestTimeout"].getInt()); + } + } else { + // Old format uses snake_case with _ms suffix + if (json.contains("connect_timeout_ms")) { + def.connect_timeout = + std::chrono::milliseconds(json["connect_timeout_ms"].getInt()); + } else if (json.contains("connect_timeout")) { + def.connect_timeout = + std::chrono::milliseconds(json["connect_timeout"].getInt()); + } + if (json.contains("request_timeout_ms")) { + def.request_timeout = + std::chrono::milliseconds(json["request_timeout_ms"].getInt()); + } else if (json.contains("request_timeout")) { + def.request_timeout = + std::chrono::milliseconds(json["request_timeout"].getInt()); + } + } + + if (json.contains("max_retries")) { + def.max_retries = static_cast(json["max_retries"].getInt()); + } + + return Result(std::move(def)); +} + +inline Result ConfigLoader::parseToolDefinition( + const JsonValue& json) { + ToolDefinition def; + + def.name = json.contains("name") ? json["name"].getString() : ""; + if (def.name.empty()) { + return Result(Error(-1, "Tool definition missing 'name'")); + } + + def.description = + json.contains("description") ? json["description"].getString() : ""; + + if (json.contains("input_schema")) { + def.input_schema = json["input_schema"]; + } + + // Parse REST endpoint + if (json.contains("rest_endpoint")) { + const auto& ep = json["rest_endpoint"]; + ToolDefinition::RESTEndpoint rest; + + rest.method = parseHttpMethod( + ep.contains("method") ? ep["method"].getString() : "GET"); + rest.url = + substituteEnvVars(ep.contains("url") ? ep["url"].getString() : ""); + + if (ep.contains("headers") && ep["headers"].isObject()) { + for (auto it = ep["headers"].begin(); it != ep["headers"].end(); ++it) { + auto kv = *it; + rest.headers[kv.first] = substituteEnvVars(kv.second.getString()); + } + } + + if (ep.contains("query_params") && ep["query_params"].isObject()) { + for (auto it = ep["query_params"].begin(); it != ep["query_params"].end(); + ++it) { + auto kv = *it; + rest.query_params[kv.first] = substituteEnvVars(kv.second.getString()); + } + } + + if (ep.contains("path_params") && ep["path_params"].isObject()) { + for (auto it = ep["path_params"].begin(); it != ep["path_params"].end(); + ++it) { + auto kv = *it; + rest.path_params[kv.first] = kv.second.getString(); + } + } + + if (ep.contains("body_mapping") && ep["body_mapping"].isObject()) { + for (auto it = ep["body_mapping"].begin(); it != ep["body_mapping"].end(); + ++it) { + auto kv = *it; + rest.body_mapping[kv.first] = kv.second.getString(); + } + } + + rest.response_path = + ep.contains("response_path") ? ep["response_path"].getString() : ""; + def.rest_endpoint = std::move(rest); + } + + // Parse MCP reference + if (json.contains("mcp_reference")) { + const auto& ref = json["mcp_reference"]; + ToolDefinition::MCPToolRef mcp; + mcp.server_name = + ref.contains("server_name") ? ref["server_name"].getString() : ""; + mcp.tool_name = + ref.contains("tool_name") ? ref["tool_name"].getString() : ""; + def.mcp_reference = std::move(mcp); + } + + // Parse tags + if (json.contains("tags") && json["tags"].isArray()) { + const auto& tags = json["tags"]; + for (size_t i = 0; i < tags.size(); ++i) { + def.tags.push_back(tags[i].getString()); + } + } + + def.require_approval = json.contains("require_approval") + ? json["require_approval"].getBool() + : false; + + return Result(std::move(def)); +} + +inline Result ConfigLoader::loadFromJson( + const JsonValue& json) { + RegistryConfig config; + + // Check if this is a new API response format + bool is_api_response = json.contains("succeeded") && json.contains("data"); + + const JsonValue* config_root = &json; + if (is_api_response) { + // Validate API response format + if (!json["succeeded"].getBool()) { + std::string message = json.contains("message") ? json["message"].getString() : "API request failed"; + return Result(Error(-1, "API Error: " + message)); + } + + if (!json.contains("data") || !json["data"].isObject()) { + return Result(Error(-1, "Invalid API response: missing or invalid 'data' field")); + } + + config_root = &json["data"]; + } + + config.name = config_root->contains("name") ? (*config_root)["name"].getString() : "tool-registry"; + config.base_url = substituteEnvVars( + config_root->contains("base_url") ? (*config_root)["base_url"].getString() : ""); + + // Parse default headers + if (config_root->contains("default_headers") && (*config_root)["default_headers"].isObject()) { + for (auto it = (*config_root)["default_headers"].begin(); + it != (*config_root)["default_headers"].end(); ++it) { + auto kv = *it; + config.default_headers[kv.first] = + substituteEnvVars(kv.second.getString()); + } + } + + // Parse auth presets + if (config_root->contains("auth_presets") && (*config_root)["auth_presets"].isObject()) { + for (auto it = (*config_root)["auth_presets"].begin(); + it != (*config_root)["auth_presets"].end(); ++it) { + auto kv = *it; + auto auth_result = parseAuthPreset(kv.second); + if (mcp::holds_alternative(auth_result)) { + config.auth_presets[kv.first] = mcp::get(auth_result); + } + } + } + + // Parse MCP servers - handle both old and new formats + const JsonValue* servers_array = nullptr; + if (is_api_response && config_root->contains("servers") && (*config_root)["servers"].isArray()) { + // New API response format: data.servers + servers_array = &(*config_root)["servers"]; + } else if (config_root->contains("mcp_servers") && (*config_root)["mcp_servers"].isArray()) { + // Old format: mcp_servers + servers_array = &(*config_root)["mcp_servers"]; + } + + if (servers_array) { + for (size_t i = 0; i < servers_array->size(); ++i) { + auto server_result = parseMCPServerDefinition((*servers_array)[i], is_api_response); + if (mcp::holds_alternative(server_result)) { + config.mcp_servers.push_back( + std::move(mcp::get(server_result))); + } + } + } + + // Parse tools + if (config_root->contains("tools") && (*config_root)["tools"].isArray()) { + const auto& tools = (*config_root)["tools"]; + for (size_t i = 0; i < tools.size(); ++i) { + auto tool_result = parseToolDefinition(tools[i]); + if (mcp::holds_alternative(tool_result)) { + config.tools.push_back( + std::move(mcp::get(tool_result))); + } + } + } + + return Result(std::move(config)); +} + +inline Result ConfigLoader::loadFromString( + const std::string& json_string) { + try { + JsonValue json = JsonValue::parse(json_string); + return loadFromJson(json); + } catch (const std::exception& e) { + return Result( + Error(-1, std::string("JSON parse error: ") + e.what())); + } +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/rest_tool_adapter.h b/third_party/gopher-orch/include/gopher/orch/agent/rest_tool_adapter.h new file mode 100644 index 00000000..aaf45e45 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/rest_tool_adapter.h @@ -0,0 +1,294 @@ +#pragma once + +// RESTToolAdapter - Create tools from REST endpoint definitions +// +// Converts ToolDefinition with RESTEndpoint to executable tools. +// Supports: +// - Path parameter substitution (/users/{id}) +// - Query parameter mapping ($.field) +// - Request body mapping +// - Response path extraction +// - Environment variable substitution + +#include +#include +#include +#include +#include + +#include "gopher/orch/agent/tool_definition.h" +#include "gopher/orch/server/rest_server.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::server; + +// Tool execution function signature (also defined in tool_registry.h) +using ToolFunction = std::function; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON PATH UTILITIES +// ═══════════════════════════════════════════════════════════════════════════ + +// Extract value from JSON using simple path ($.field.subfield) +inline JsonValue extractJsonPath(const JsonValue& json, + const std::string& path) { + if (path.empty() || path == "$") { + return json; + } + + // Remove leading "$." if present + std::string clean_path = path; + if (clean_path.substr(0, 2) == "$.") { + clean_path = clean_path.substr(2); + } else if (clean_path[0] == '$') { + clean_path = clean_path.substr(1); + } + + // Split by dots and traverse + JsonValue current = json; + std::istringstream iss(clean_path); + std::string token; + + while (std::getline(iss, token, '.')) { + if (token.empty()) + continue; + + // Check for array index [n] + auto bracket_pos = token.find('['); + if (bracket_pos != std::string::npos) { + std::string field = token.substr(0, bracket_pos); + std::string index_str = token.substr(bracket_pos + 1); + index_str.pop_back(); // Remove ] + + if (!field.empty()) { + if (!current.contains(field)) { + return JsonValue(); + } + current = current[field]; + } + + int index = std::stoi(index_str); + if (!current.isArray() || index >= static_cast(current.size())) { + return JsonValue(); + } + current = current[index]; + } else { + if (!current.isObject() || !current.contains(token)) { + return JsonValue(); + } + current = current[token]; + } + } + + return current; +} + +// Extract value as string +inline std::string extractJsonPathString(const JsonValue& json, + const std::string& path) { + JsonValue value = extractJsonPath(json, path); + if (value.isNull()) { + return ""; + } + if (value.isString()) { + return value.getString(); + } + return value.toString(); +} + +// URL encode string +inline std::string urlEncode(const std::string& str) { + std::string encoded; + for (char c : str) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + encoded += c; + } else { + char hex[4]; + std::snprintf(hex, sizeof(hex), "%%%02X", static_cast(c)); + encoded += hex; + } + } + return encoded; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// REST TOOL ADAPTER +// ═══════════════════════════════════════════════════════════════════════════ + +class RESTToolAdapter { + public: + explicit RESTToolAdapter(HttpClientPtr http_client = nullptr) + : http_client_(http_client ? http_client + : std::make_shared()) {} + + // Set default headers for all requests + void setDefaultHeaders(const std::map& headers) { + default_headers_ = headers; + } + + // Set base URL for relative paths + void setBaseUrl(const std::string& url) { base_url_ = url; } + + // Set environment variable for substitution + void setEnv(const std::string& name, const std::string& value) { + env_vars_[name] = value; + } + + // Create a tool function from REST endpoint definition + ToolFunction createToolFunction(const ToolDefinition& def) { + if (!def.rest_endpoint) { + return nullptr; + } + + const auto& endpoint = *def.rest_endpoint; + + return [this, endpoint](const JsonValue& input, Dispatcher& dispatcher, + JsonCallback callback) { + executeRESTCall(endpoint, input, dispatcher, std::move(callback)); + }; + } + + // Execute a REST call directly + void executeRESTCall(const ToolDefinition::RESTEndpoint& endpoint, + const JsonValue& input, + Dispatcher& dispatcher, + JsonCallback callback) { + // Build URL + std::string url = substituteEnvVars(endpoint.url); + + // Add base URL if path is relative + if (!url.empty() && url[0] == '/') { + url = base_url_ + url; + } + + // Substitute path parameters + for (const auto& kv : endpoint.path_params) { + std::string value = extractJsonPathString(input, kv.second); + std::regex param_regex("\\{" + kv.first + "\\}"); + url = std::regex_replace(url, param_regex, urlEncode(value)); + } + + // Build query string + if (!endpoint.query_params.empty()) { + bool has_query = url.find('?') != std::string::npos; + for (const auto& kv : endpoint.query_params) { + std::string value = + substituteEnvVars(extractJsonPathString(input, kv.second)); + if (!value.empty()) { + url += (has_query ? "&" : "?"); + url += urlEncode(kv.first) + "=" + urlEncode(value); + has_query = true; + } + } + } + + // Build headers + std::map headers = default_headers_; + for (const auto& kv : endpoint.headers) { + headers[kv.first] = substituteEnvVars(kv.second); + } + if (headers.find("Content-Type") == headers.end()) { + headers["Content-Type"] = "application/json"; + } + + // Build body for POST/PUT/PATCH + std::string body; + if (endpoint.method == HttpMethod::POST || + endpoint.method == HttpMethod::PUT || + endpoint.method == HttpMethod::PATCH) { + if (!endpoint.body_mapping.empty()) { + JsonValue body_json = JsonValue::object(); + for (const auto& kv : endpoint.body_mapping) { + body_json[kv.first] = extractJsonPath(input, kv.second); + } + body = body_json.toString(); + } else { + body = input.toString(); + } + } + + // Make request + http_client_->request( + endpoint.method, url, headers, body, dispatcher, + [endpoint, + callback = std::move(callback)](Result result) { + if (!mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + return; + } + + auto& response = mcp::get(result); + if (!response.isSuccess()) { + callback(Result( + Error(-1, "HTTP " + std::to_string(response.status_code) + + ": " + response.body))); + return; + } + + // Parse response + JsonValue json; + try { + if (!response.body.empty()) { + json = JsonValue::parse(response.body); + } else { + json = JsonValue::object(); + } + } catch (...) { + // If not JSON, wrap as string + json = response.body; + } + + // Extract with path if specified + if (!endpoint.response_path.empty()) { + json = extractJsonPath(json, endpoint.response_path); + } + + callback(Result(std::move(json))); + }); + } + + private: + std::string substituteEnvVars(const std::string& input) const { + std::string result = input; + std::regex env_pattern("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}"); + std::smatch match; + + while (std::regex_search(result, match, env_pattern)) { + std::string var_name = match[1].str(); + std::string value; + + auto it = env_vars_.find(var_name); + if (it != env_vars_.end()) { + value = it->second; + } else { + const char* env_val = std::getenv(var_name.c_str()); + if (env_val) { + value = env_val; + } + } + + result = result.replace(match.position(), match.length(), value); + } + + return result; + } + + HttpClientPtr http_client_; + std::map default_headers_; + std::string base_url_; + std::map env_vars_; +}; + +using RESTToolAdapterPtr = std::shared_ptr; + +inline RESTToolAdapterPtr makeRESTToolAdapter(HttpClientPtr client = nullptr) { + return std::make_shared(client); +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/tool_definition.h b/third_party/gopher-orch/include/gopher/orch/agent/tool_definition.h new file mode 100644 index 00000000..49e1b6c7 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/tool_definition.h @@ -0,0 +1,354 @@ +#pragma once + +// Tool Definition Types - Configuration-driven tool definitions +// +// Provides structured types for defining tools from: +// - REST API endpoints +// - MCP server references +// - Lambda functions +// +// Supports JSON configuration with environment variable substitution. + +#include +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" +#include "gopher/orch/llm/llm_types.h" +#include "gopher/orch/server/rest_server.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; +using namespace gopher::orch::llm; +using namespace gopher::orch::server; + +// ═══════════════════════════════════════════════════════════════════════════ +// TOOL DEFINITION - Unified tool configuration +// ═══════════════════════════════════════════════════════════════════════════ + +struct ToolDefinition { + std::string name; + std::string description; + JsonValue input_schema; // JSON Schema for parameters + + // ───────────────────────────────────────────────────────────────────────── + // Option 1: REST Endpoint + // ───────────────────────────────────────────────────────────────────────── + struct RESTEndpoint { + HttpMethod method = HttpMethod::GET; + std::string url; // Full URL or path (supports ${ENV_VAR}) + std::map headers; + + // Parameter mapping (JSONPath-like expressions: $.field) + std::map query_params; // {"q": "$.query"} + std::map path_params; // {"id": "$.user_id"} + std::map body_mapping; // For POST body + + // Response extraction + std::string response_path; // JSONPath to extract from response + + RESTEndpoint() = default; + }; + optional rest_endpoint; + + // ───────────────────────────────────────────────────────────────────────── + // Option 2: MCP Server Reference + // ───────────────────────────────────────────────────────────────────────── + struct MCPToolRef { + std::string server_name; // Name of registered MCP server + std::string tool_name; // Tool name on that server + + MCPToolRef() = default; + MCPToolRef(const std::string& server, const std::string& tool) + : server_name(server), tool_name(tool) {} + }; + optional mcp_reference; + + // ───────────────────────────────────────────────────────────────────────── + // Option 3: Lambda/Function (programmatic only) + // ───────────────────────────────────────────────────────────────────────── + using Handler = + std::function; + optional handler; + + // Metadata + std::vector tags; + bool require_approval = false; // Human-in-the-loop + + ToolDefinition() = default; + + // Builder pattern + ToolDefinition& withName(const std::string& n) { + name = n; + return *this; + } + + ToolDefinition& withDescription(const std::string& desc) { + description = desc; + return *this; + } + + ToolDefinition& withInputSchema(const JsonValue& schema) { + input_schema = schema; + return *this; + } + + ToolDefinition& withRESTEndpoint(const RESTEndpoint& ep) { + rest_endpoint = ep; + return *this; + } + + ToolDefinition& withMCPReference(const std::string& server, + const std::string& tool) { + mcp_reference = MCPToolRef(server, tool); + return *this; + } + + ToolDefinition& withHandler(Handler h) { + handler = std::move(h); + return *this; + } + + ToolDefinition& withTag(const std::string& tag) { + tags.push_back(tag); + return *this; + } + + ToolDefinition& withApprovalRequired(bool required = true) { + require_approval = required; + return *this; + } + + // Convert to ToolSpec for LLM + ToolSpec toToolSpec() const { + ToolSpec spec; + spec.name = name; + spec.description = description; + spec.parameters = input_schema; + return spec; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// MCP SERVER DEFINITION - Remote MCP server configuration +// ═══════════════════════════════════════════════════════════════════════════ + +struct MCPServerDefinition { + std::string name; + + enum class TransportType { STDIO, HTTP_SSE, WEBSOCKET }; + TransportType transport = TransportType::STDIO; + + // STDIO transport + struct StdioConfig { + std::string command; + std::vector args; + std::map env; + std::string working_directory; + + StdioConfig() = default; + StdioConfig(const std::string& cmd, + const std::vector& arguments = {}) + : command(cmd), args(arguments) {} + }; + optional stdio_config; + + // HTTP-SSE transport + struct HttpSseConfig { + std::string url; + std::map headers; + bool verify_ssl = true; + + HttpSseConfig() = default; + explicit HttpSseConfig(const std::string& u) : url(u) {} + }; + optional http_sse_config; + + // WebSocket transport + struct WebSocketConfig { + std::string url; + std::map headers; + bool verify_ssl = true; + + WebSocketConfig() = default; + explicit WebSocketConfig(const std::string& u) : url(u) {} + }; + optional websocket_config; + + // Connection settings + std::chrono::milliseconds connect_timeout{30000}; + std::chrono::milliseconds request_timeout{60000}; + uint32_t max_retries = 3; + + MCPServerDefinition() = default; + explicit MCPServerDefinition(const std::string& n) : name(n) {} + + // Builder pattern for STDIO + static MCPServerDefinition stdio(const std::string& name, + const std::string& command, + const std::vector& args = {}) { + MCPServerDefinition def(name); + def.transport = TransportType::STDIO; + def.stdio_config = StdioConfig(command, args); + return def; + } + + // Builder pattern for HTTP-SSE + static MCPServerDefinition httpSse(const std::string& name, + const std::string& url) { + MCPServerDefinition def(name); + def.transport = TransportType::HTTP_SSE; + def.http_sse_config = HttpSseConfig(url); + return def; + } + + // Builder pattern for WebSocket + static MCPServerDefinition websocket(const std::string& name, + const std::string& url) { + MCPServerDefinition def(name); + def.transport = TransportType::WEBSOCKET; + def.websocket_config = WebSocketConfig(url); + return def; + } + + MCPServerDefinition& withEnv(const std::string& key, + const std::string& value) { + if (stdio_config) { + stdio_config->env[key] = value; + } + return *this; + } + + MCPServerDefinition& withHeader(const std::string& key, + const std::string& value) { + if (http_sse_config) { + http_sse_config->headers[key] = value; + } else if (websocket_config) { + websocket_config->headers[key] = value; + } + return *this; + } + + MCPServerDefinition& withTimeout(std::chrono::milliseconds timeout) { + connect_timeout = timeout; + request_timeout = timeout; + return *this; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// AUTH PRESET - Reusable authentication configuration +// ═══════════════════════════════════════════════════════════════════════════ + +struct AuthPreset { + enum class Type { BEARER, API_KEY, BASIC }; + Type type = Type::BEARER; + + std::string value; // Token/key (supports ${ENV_VAR}) + std::string header = "Authorization"; // Header name for API_KEY + + AuthPreset() = default; + + static AuthPreset bearer(const std::string& token) { + AuthPreset auth; + auth.type = Type::BEARER; + auth.value = token; + return auth; + } + + static AuthPreset apiKey(const std::string& key, + const std::string& header_name = "X-API-Key") { + AuthPreset auth; + auth.type = Type::API_KEY; + auth.value = key; + auth.header = header_name; + return auth; + } + + static AuthPreset basic(const std::string& credentials) { + AuthPreset auth; + auth.type = Type::BASIC; + auth.value = credentials; + return auth; + } + + // Build header value + std::string headerValue() const { + switch (type) { + case Type::BEARER: + return "Bearer " + value; + case Type::BASIC: + return "Basic " + value; + case Type::API_KEY: + return value; + default: + return value; + } + } + + // Get header name + std::string headerName() const { + if (type == Type::API_KEY) { + return header; + } + return "Authorization"; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// REGISTRY CONFIG - Complete configuration file structure +// ═══════════════════════════════════════════════════════════════════════════ + +struct RegistryConfig { + std::string name = "tool-registry"; + std::string base_url; // Default base URL for REST tools + std::map default_headers; + + // Authentication presets (reusable) + std::map auth_presets; + + // MCP servers to connect + std::vector mcp_servers; + + // Tool definitions + std::vector tools; + + RegistryConfig() = default; + explicit RegistryConfig(const std::string& n) : name(n) {} + + // Builder pattern + RegistryConfig& withBaseUrl(const std::string& url) { + base_url = url; + return *this; + } + + RegistryConfig& withHeader(const std::string& key, const std::string& value) { + default_headers[key] = value; + return *this; + } + + RegistryConfig& withAuthPreset(const std::string& name, + const AuthPreset& auth) { + auth_presets[name] = auth; + return *this; + } + + RegistryConfig& withMCPServer(const MCPServerDefinition& server) { + mcp_servers.push_back(server); + return *this; + } + + RegistryConfig& withTool(const ToolDefinition& tool) { + tools.push_back(tool); + return *this; + } +}; + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/tool_executor.h b/third_party/gopher-orch/include/gopher/orch/agent/tool_executor.h new file mode 100644 index 00000000..7be5c295 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/tool_executor.h @@ -0,0 +1,156 @@ +#pragma once + +// ToolExecutor - Executes tools from a ToolRegistry +// +// Separates execution concerns from the registry: +// - ToolRegistry: stores and retrieves tool definitions +// - ToolExecutor: looks up and executes tools +// +// Usage: +// auto registry = makeToolRegistry(); +// registry->addTool("calculator", "Perform calculations", schema, handler); +// +// auto executor = makeToolExecutor(registry); +// executor->executeTool("calculator", args, dispatcher, callback); + +#include +#include +#include +#include + +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/core/types.h" +#include "gopher/orch/llm/llm_types.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; +using namespace gopher::orch::llm; + +// Forward declaration +class ToolExecutor; +using ToolExecutorPtr = std::shared_ptr; + +// ToolExecutor - Executes tools by looking them up in a registry +// +// Thread Safety: +// - All execution methods are thread-safe +// - Callbacks are invoked in the dispatcher thread context +class ToolExecutor { + public: + using Ptr = std::shared_ptr; + + explicit ToolExecutor(ToolRegistryPtr registry) + : registry_(std::move(registry)) {} + ~ToolExecutor() = default; + + // Factory + static Ptr create(ToolRegistryPtr registry) { + return std::make_shared(std::move(registry)); + } + + // Get the underlying registry + ToolRegistryPtr registry() const { return registry_; } + + // ═══════════════════════════════════════════════════════════════════════════ + // TOOL EXECUTION + // ═══════════════════════════════════════════════════════════════════════════ + + // Execute a tool by name + void executeTool(const std::string& name, + const JsonValue& arguments, + Dispatcher& dispatcher, + JsonCallback callback) { + if (!registry_) { + dispatcher.post([callback = std::move(callback)]() { + callback(Result(Error(-1, "No registry configured"))); + }); + return; + } + + auto entry_opt = registry_->getToolEntry(name); + if (!entry_opt.has_value()) { + dispatcher.post([callback = std::move(callback), name]() { + callback(Result(Error(-1, "Tool not found: " + name))); + }); + return; + } + + const auto& entry = entry_opt.value(); + + if (entry.isLocal()) { + // Execute local function + entry.function(arguments, dispatcher, std::move(callback)); + } else { + // Try composite first if available (unified execution path) + auto composite = registry_->getServerComposite(); + if (composite) { + auto tool = composite->tool(name); + if (tool) { + RunnableConfig config; + tool->invoke(arguments, config, dispatcher, std::move(callback)); + return; + } + } + + // Fallback: direct server call (backward compat when no composite) + RunnableConfig config; + std::string tool_name = + entry.original_name.empty() ? entry.spec.name : entry.original_name; + + entry.server->callTool(tool_name, arguments, config, dispatcher, + std::move(callback)); + } + } + + // Execute a ToolCall (convenience method) + void executeToolCall(const ToolCall& call, + Dispatcher& dispatcher, + JsonCallback callback) { + executeTool(call.name, call.arguments, dispatcher, std::move(callback)); + } + + // Execute multiple tool calls (optionally in parallel) + void executeToolCalls( + const std::vector& calls, + bool parallel, + Dispatcher& dispatcher, + std::function>)> callback) { + if (calls.empty()) { + dispatcher.post([callback = std::move(callback)]() { callback({}); }); + return; + } + + auto results = + std::make_shared>>(calls.size()); + auto pending = std::make_shared>(calls.size()); + + for (size_t i = 0; i < calls.size(); ++i) { + executeToolCall( + calls[i], dispatcher, + [results, pending, i, callback](Result result) { + (*results)[i] = std::move(result); + if (--(*pending) == 0) { + callback(std::move(*results)); + } + }); + + // Note: True sequential execution would require callback chaining + // This implementation executes all calls and collects results + } + } + + private: + ToolRegistryPtr registry_; +}; + +// Convenience function to create executor +inline ToolExecutorPtr makeToolExecutor(ToolRegistryPtr registry) { + return ToolExecutor::create(std::move(registry)); +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/tool_registry.h b/third_party/gopher-orch/include/gopher/orch/agent/tool_registry.h new file mode 100644 index 00000000..12342023 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/tool_registry.h @@ -0,0 +1,522 @@ +#pragma once + +// ToolRegistry - Tool repository for agents +// +// Stores and retrieves tools from multiple sources: +// - Local lambda functions +// - MCP servers (via Server interface) +// - REST endpoints (via JSON config) +// - JSON configuration files +// +// This is a pure repository - for execution, use ToolExecutor. +// +// Usage: +// auto registry = makeToolRegistry(); +// +// // Option 1: Load from JSON config +// registry->loadFromFile("tools.json", dispatcher, callback); +// +// // Option 2: Add tools programmatically +// registry->addTool("calculator", "Perform calculations", schema, handler); +// +// // Option 3: Add from MCP server +// registry->addServer(mcpServer); +// +// // Get specs for LLM +// auto specs = registry->getToolSpecs(); +// +// // For execution, use ToolExecutor: +// auto executor = makeToolExecutor(registry); +// executor->executeTool("calculator", args, dispatcher, callback); + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" +#include "gopher/orch/llm/llm_types.h" +#include "gopher/orch/server/server.h" +#include "gopher/orch/server/server_composite.h" + +// Forward declarations for config loading +namespace gopher { +namespace orch { +namespace agent { +struct ToolDefinition; +struct MCPServerDefinition; +struct RegistryConfig; +class ConfigLoader; +class RESTToolAdapter; +} // namespace agent +} // namespace orch +} // namespace gopher + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; +using namespace gopher::orch::llm; +using namespace gopher::orch::server; + +// Forward declaration +class ToolRegistry; +using ToolRegistryPtr = std::shared_ptr; + +// Tool execution function signature +using ToolFunction = std::function; + +// ═══════════════════════════════════════════════════════════════════════════ +// CONVERSION UTILITIES +// ═══════════════════════════════════════════════════════════════════════════ + +// Convert ServerToolInfo (from Server) to ToolSpec (for LLM) +inline ToolSpec toToolSpec(const ServerToolInfo& info) { + ToolSpec spec; + spec.name = info.name; + spec.description = info.description; + spec.parameters = info.inputSchema; + return spec; +} + +// Convert ToolSpec (from LLM) to ServerToolInfo (for Server) +inline ServerToolInfo toServerToolInfo(const ToolSpec& spec) { + ServerToolInfo info; + info.name = spec.name; + info.description = spec.description; + info.inputSchema = spec.parameters; + return info; +} + +// Internal tool entry +struct ToolEntry { + ToolSpec spec; + ToolFunction function; + ServerPtr server; // nullptr for local tools + std::string + original_name; // Original name on server (may differ from spec.name) + + bool isLocal() const { return server == nullptr; } + bool isRemote() const { return server != nullptr; } +}; + +// ToolRegistry - Tool repository for agents +// +// Thread Safety: +// - Configuration methods (addTool, addServer) should be called before use +// - Read methods (getToolSpecs, getToolEntry) are thread-safe after +// configuration +class ToolRegistry { + public: + using Ptr = std::shared_ptr; + + ToolRegistry() = default; + ~ToolRegistry() = default; + + // Factory + static Ptr create() { return std::make_shared(); } + + // ═══════════════════════════════════════════════════════════════════════════ + // SERVER COMPOSITE DELEGATION + // ═══════════════════════════════════════════════════════════════════════════ + + // Set the underlying ServerComposite for delegated server operations + void setServerComposite(ServerCompositePtr composite) { + std::lock_guard lock(mutex_); + server_composite_ = std::move(composite); + } + + // Get the underlying ServerComposite + ServerCompositePtr getServerComposite() const { + std::lock_guard lock(mutex_); + return server_composite_; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LOCAL TOOLS + // ═══════════════════════════════════════════════════════════════════════════ + + // Add a local tool with lambda function + void addTool(const std::string& name, + const std::string& description, + const JsonValue& parameters, + ToolFunction function) { + std::lock_guard lock(mutex_); + + ToolEntry entry; + entry.spec.name = name; + entry.spec.description = description; + entry.spec.parameters = parameters; + entry.function = std::move(function); + entry.server = nullptr; + + tools_[name] = std::move(entry); + } + + // Add a local tool with ToolSpec + void addTool(const ToolSpec& spec, ToolFunction function) { + addTool(spec.name, spec.description, spec.parameters, std::move(function)); + } + + // Add a synchronous tool (wraps in async callback) + void addSyncTool( + const std::string& name, + const std::string& description, + const JsonValue& parameters, + std::function(const JsonValue&)> function) { + addTool(name, description, parameters, + [func = std::move(function)](const JsonValue& args, + Dispatcher& dispatcher, + JsonCallback callback) { + auto result = func(args); + dispatcher.post([callback = std::move(callback), + result = std::move(result)]() { + callback(std::move(result)); + }); + }); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // REMOTE TOOLS (MCP/REST Servers) + // ═══════════════════════════════════════════════════════════════════════════ + + // Add all tools from a server (async - fetches tool list) + void addServer(ServerPtr server, Dispatcher& dispatcher) { + if (!server) + return; + + // Store server reference + { + std::lock_guard lock(mutex_); + servers_.push_back(server); + } + + // Capture composite for lambda (may be nullptr) + ServerCompositePtr composite; + { + std::lock_guard lock(mutex_); + composite = server_composite_; + } + + // List and register tools + server->listTools( + dispatcher, [this, server, composite](Result> result) { + if (!mcp::holds_alternative>(result)) + return; + + auto tools = mcp::get>(result); + + // If composite is set, delegate server registration to it + if (composite) { + std::vector tool_names; + tool_names.reserve(tools.size()); + for (const auto& t : tools) { + tool_names.push_back(t.name); + } + // Add to composite without namespacing (ToolRegistry handles its own naming) + composite->addServer(server, tool_names, false); + } + + // Register tool specs for LLM (execution goes through composite if available) + std::lock_guard lock(mutex_); + for (const auto& info : tools) { + ToolEntry entry; + entry.spec = toToolSpec(info); // Use conversion utility + entry.server = server; + entry.original_name = info.name; + + // Use prefixed name to avoid conflicts + std::string prefixed_key = server->name() + ":" + info.name; + tools_[prefixed_key] = entry; + + // Also register without prefix if no conflict + if (tools_.find(info.name) == tools_.end()) { + tools_[info.name] = entry; + } + } + }); + } + + // Add all tools from a server (sync - provide tool list directly) + void addServer(ServerPtr server, const std::vector& tools) { + if (!server) + return; + + std::lock_guard lock(mutex_); + servers_.push_back(server); + + // If composite is set, delegate server registration to it + if (server_composite_) { + std::vector tool_names; + tool_names.reserve(tools.size()); + for (const auto& t : tools) { + tool_names.push_back(t.name); + } + // Add to composite without namespacing (ToolRegistry handles its own naming) + server_composite_->addServer(server, tool_names, false); + } + + // Register tool specs for LLM + for (const auto& info : tools) { + ToolEntry entry; + entry.spec = toToolSpec(info); + entry.server = server; + entry.original_name = info.name; + + std::string prefixed_key = server->name() + ":" + info.name; + tools_[prefixed_key] = entry; + + if (tools_.find(info.name) == tools_.end()) { + tools_[info.name] = entry; + } + } + } + + // Add specific tool from a server with ServerToolInfo + void addServerTool(ServerPtr server, + const ServerToolInfo& info, + const std::string& alias = "") { + if (!server) + return; + + std::lock_guard lock(mutex_); + + ToolEntry entry; + entry.spec = toToolSpec(info); + if (!alias.empty()) { + entry.spec.name = alias; // Override name with alias + } + entry.server = server; + entry.original_name = info.name; + + std::string key = alias.empty() ? info.name : alias; + tools_[key] = std::move(entry); + } + + // Add specific tool from a server by name (spec fetched later) + void addServerTool(ServerPtr server, + const std::string& tool_name, + const std::string& alias = "") { + if (!server) + return; + + std::lock_guard lock(mutex_); + + ToolEntry entry; + entry.spec.name = alias.empty() ? tool_name : alias; + entry.server = server; + entry.original_name = tool_name; + + std::string key = alias.empty() ? tool_name : alias; + tools_[key] = std::move(entry); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TOOL ACCESS + // ═══════════════════════════════════════════════════════════════════════════ + + // Get tool specs for LLM + std::vector getToolSpecs() const { + std::lock_guard lock(mutex_); + + std::vector specs; + std::set seen_names; // Track unique tool names + + // Return unique tools by spec.name + // Prefer non-prefixed entries over prefixed ones + for (const auto& pair : tools_) { + const auto& spec = pair.second.spec; + + // Skip if we've already seen a tool with this name + if (seen_names.find(spec.name) != seen_names.end()) { + continue; + } + + // Skip prefixed entries if the non-prefixed version exists + // (Prefixed entries have ':' in the map key) + if (pair.first.find(':') != std::string::npos) { + // Check if non-prefixed version exists + auto it = tools_.find(spec.name); + if (it != tools_.end()) { + continue; // Skip this prefixed entry + } + } + + specs.push_back(spec); + seen_names.insert(spec.name); + } + + return specs; + } + + // Get a specific tool's spec + optional getToolSpec(const std::string& name) const { + std::lock_guard lock(mutex_); + auto it = tools_.find(name); + if (it == tools_.end()) { + return nullopt; + } + return it->second.spec; + } + + // Get tool entry (for advanced usage) + optional getToolEntry(const std::string& name) const { + std::lock_guard lock(mutex_); + auto it = tools_.find(name); + if (it == tools_.end()) { + return nullopt; + } + return it->second; + } + + // Check if tool exists + bool hasTool(const std::string& name) const { + std::lock_guard lock(mutex_); + return tools_.find(name) != tools_.end(); + } + + // Get tool names + std::vector getToolNames() const { + std::lock_guard lock(mutex_); + + std::vector names; + names.reserve(tools_.size()); + + for (const auto& pair : tools_) { + names.push_back(pair.first); + } + + return names; + } + + // Get tool count (unique tools only, excluding duplicate registrations) + size_t toolCount() const { + std::lock_guard lock(mutex_); + + std::set seen_names; // Track unique tool names + + // Count unique tools by spec.name (same logic as getToolSpecs) + for (const auto& pair : tools_) { + const auto& spec = pair.second.spec; + + // Skip if we've already seen a tool with this name + if (seen_names.find(spec.name) != seen_names.end()) { + continue; + } + + // Skip prefixed entries if the non-prefixed version exists + if (pair.first.find(':') != std::string::npos) { + auto it = tools_.find(spec.name); + if (it != tools_.end()) { + continue; // Skip this prefixed entry + } + } + + seen_names.insert(spec.name); + } + + return seen_names.size(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════ + + // Remove a tool + void removeTool(const std::string& name) { + std::lock_guard lock(mutex_); + tools_.erase(name); + } + + // Clear all tools + void clear() { + std::lock_guard lock(mutex_); + tools_.clear(); + servers_.clear(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CONFIG LOADING (requires tool_definition.h, config_loader.h, + // rest_tool_adapter.h) + // ═══════════════════════════════════════════════════════════════════════════ + + // Load from JSON config file + // Requires: #include "gopher/orch/agent/config_loader.h" + // #include "gopher/orch/agent/rest_tool_adapter.h" + void loadFromFile(const std::string& path, + Dispatcher& dispatcher, + std::function callback); + + // Load from JSON string + void loadFromString(const std::string& json_string, + Dispatcher& dispatcher, + std::function callback); + + // Load from RegistryConfig struct + void loadConfig(const RegistryConfig& config, + Dispatcher& dispatcher, + std::function callback); + + // Register a tool from ToolDefinition + VoidResult registerTool(const ToolDefinition& def, Dispatcher& dispatcher); + + // ═══════════════════════════════════════════════════════════════════════════ + // ENVIRONMENT VARIABLES + // ═══════════════════════════════════════════════════════════════════════════ + + // Set environment variable for ${VAR} substitution + void setEnv(const std::string& name, const std::string& value) { + std::lock_guard lock(mutex_); + env_vars_[name] = value; + } + + // Load environment from .env file + VoidResult loadEnvFile(const std::string& path); + + // ═══════════════════════════════════════════════════════════════════════════ + // MCP SERVER MANAGEMENT (for config loading) + // ═══════════════════════════════════════════════════════════════════════════ + + // Add MCP server from definition + void addMCPServer(const MCPServerDefinition& def, + Dispatcher& dispatcher, + std::function callback); + + // Get registered MCP server by name + ServerPtr getMCPServer(const std::string& name) const { + std::lock_guard lock(mutex_); + auto it = mcp_servers_.find(name); + return it != mcp_servers_.end() ? it->second : nullptr; + } + + // List registered MCP server names + std::vector getMCPServerNames() const { + std::lock_guard lock(mutex_); + std::vector names; + for (const auto& kv : mcp_servers_) { + names.push_back(kv.first); + } + return names; + } + + private: + mutable std::mutex mutex_; + std::map tools_; + std::vector servers_; + std::map mcp_servers_; // Named MCP servers + std::map env_vars_; // Environment variables + ServerCompositePtr server_composite_; // Delegate server tool operations +}; + +// Convenience function to create registry +inline ToolRegistryPtr makeToolRegistry() { return ToolRegistry::create(); } + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/tool_runnable.h b/third_party/gopher-orch/include/gopher/orch/agent/tool_runnable.h new file mode 100644 index 00000000..d826bf3e --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/tool_runnable.h @@ -0,0 +1,128 @@ +#pragma once + +// ToolRunnable - Wraps ToolExecutor as a composable Runnable +// +// Enables tool execution to be composed with other Runnables in pipelines, +// sequences, and graphs. Supports both single tool calls and parallel +// execution of multiple tool calls. +// +// Usage: +// auto registry = makeToolRegistry(); +// registry->addTool("search", "Search the web", schema, handler); +// auto executor = makeToolExecutor(registry); +// auto tool_runnable = ToolRunnable::create(executor); +// +// JsonValue input = JsonValue::object(); +// input["name"] = "search"; +// input["arguments"] = args; +// +// tool_runnable->invoke(input, config, dispatcher, callback); + +#include +#include + +#include "gopher/orch/agent/tool_executor.h" +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; + +// ToolRunnable - Adapter that makes ToolExecutor a Runnable +// +// Input Schema (single tool call): +// { +// "id": "call_123", // optional, used for result mapping +// "name": "search", +// "arguments": {...} +// } +// +// Input Schema (multiple tool calls - parallel execution): +// { +// "tool_calls": [ +// {"id": "call_1", "name": "search", "arguments": {...}}, +// {"id": "call_2", "name": "calculator", "arguments": {...}} +// ] +// } +// +// Output Schema (single): +// { +// "id": "call_123", +// "result": {...}, +// "success": true +// } +// +// Output Schema (multiple): +// { +// "results": [ +// {"id": "call_1", "result": {...}, "success": true}, +// {"id": "call_2", "result": 4, "success": true} +// ] +// } +class ToolRunnable : public Runnable { + public: + using Ptr = std::shared_ptr; + + // Factory method + static Ptr create(ToolExecutorPtr executor); + + // Runnable interface + std::string name() const override; + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override; + + // Accessors + ToolExecutorPtr executor() const { return executor_; } + ToolRegistryPtr registry() const { + return executor_ ? executor_->registry() : nullptr; + } + + private: + explicit ToolRunnable(ToolExecutorPtr executor); + + // Execute a single tool call + void executeSingle(const std::string& id, + const std::string& name, + const JsonValue& arguments, + Dispatcher& dispatcher, + Callback callback); + + // Execute multiple tool calls in parallel + void executeMultiple(const std::vector& calls, + Dispatcher& dispatcher, + Callback callback); + + // Parse single tool call from input + struct SingleCall { + std::string id; + std::string name; + JsonValue arguments; + bool valid = false; + }; + static SingleCall parseSingleCall(const JsonValue& input); + + // Parse multiple tool calls from input + static std::vector parseMultipleCalls(const JsonValue& input); + + ToolExecutorPtr executor_; +}; + +// Convenience factory function +inline ToolRunnable::Ptr makeToolRunnable(ToolExecutorPtr executor) { + return ToolRunnable::create(std::move(executor)); +} + +// Create ToolRunnable directly from registry +inline ToolRunnable::Ptr makeToolRunnable(ToolRegistryPtr registry) { + return ToolRunnable::create(makeToolExecutor(std::move(registry))); +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/agent/tools_fetcher.h b/third_party/gopher-orch/include/gopher/orch/agent/tools_fetcher.h new file mode 100644 index 00000000..130d6361 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/agent/tools_fetcher.h @@ -0,0 +1,100 @@ +#pragma once + +/** + * @file tools_fetcher.h + * @brief ToolsFetcher - Thin orchestration layer for JSON-to-Agent pipeline + * + * This class coordinates existing components to load tool configurations + * from JSON and create a ToolRegistry backed by ServerComposite. + */ + +#include +#include +#include + +#include "gopher/orch/core/types.h" + +// Forward declarations +namespace gopher { +namespace orch { + +namespace server { +class ServerComposite; +class MCPServer; +} // namespace server + +namespace agent { + +class ConfigLoader; +class ToolRegistry; + +using namespace gopher::orch::core; + +/** + * @class ToolsFetcher + * @brief Orchestrates tool loading from JSON configuration + * + * ToolsFetcher is a thin layer that: + * 1. Loads JSON configuration using ConfigLoader + * 2. Creates MCP servers from the configuration + * 3. Aggregates servers in a ServerComposite + * 4. Wraps the composite in a ToolRegistry for agent use + */ +class ToolsFetcher { + public: + ToolsFetcher() = default; + ~ToolsFetcher() = default; + + /** + * @brief Load tools from JSON configuration string + * @param json_config JSON configuration containing mcp_servers array + * @param dispatcher Event dispatcher for async operations + * @param callback Called when loading completes or fails + */ + void loadFromJson(const std::string& json_config, + Dispatcher& dispatcher, + std::function callback); + + /** + * @brief Load tools from JSON configuration file + * @param file_path Path to JSON configuration file + * @param dispatcher Event dispatcher for async operations + * @param callback Called when loading completes or fails + */ + void loadFromFile(const std::string& file_path, + Dispatcher& dispatcher, + std::function callback); + + /** + * @brief Get the configured ToolRegistry + * @return Shared pointer to ToolRegistry, nullptr if not loaded + */ + std::shared_ptr getRegistry() const { return registry_; } + + /** + * @brief Get the underlying ServerComposite + * @return Shared pointer to ServerComposite, nullptr if not loaded + */ + std::shared_ptr getComposite() const { + return composite_; + } + + /** + * @brief Shutdown all connections (SSE connections are long-lived) + * @param dispatcher Event dispatcher for async operations + * @param callback Called when shutdown completes + * + * This must be called before program exit to close SSE connections, + * otherwise the event loop will keep running indefinitely. + */ + void shutdown(Dispatcher& dispatcher, std::function callback); + + private: + std::shared_ptr config_loader_; + std::shared_ptr composite_; + std::shared_ptr registry_; +}; + +} // namespace agent +} // namespace orch +} // namespace gopher \ No newline at end of file diff --git a/third_party/gopher-orch/include/gopher/orch/callback/callback_handler.h b/third_party/gopher-orch/include/gopher/orch/callback/callback_handler.h new file mode 100644 index 00000000..eecfc470 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/callback/callback_handler.h @@ -0,0 +1,292 @@ +#pragma once + +// CallbackHandler - Interface for receiving observability events +// +// Provides hooks for monitoring execution of chains, tools, and custom events. +// Implementations can log, trace, or perform other observability tasks. +// +// All handler methods have default empty implementations, allowing handlers +// to override only the events they care about. + +#include +#include +#include + +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +namespace callback { + +// ============================================================================= +// EventType - Categories of observable events +// ============================================================================= + +enum class EventType { + CHAIN_START, // Runnable chain begins execution + CHAIN_END, // Runnable chain completes successfully + CHAIN_ERROR, // Runnable chain fails with error + TOOL_START, // Tool invocation begins + TOOL_END, // Tool invocation completes successfully + TOOL_ERROR, // Tool invocation fails with error + LLM_START, // LLM request begins (future use) + LLM_END, // LLM request completes (future use) + LLM_ERROR, // LLM request fails (future use) + CUSTOM // User-defined custom event +}; + +// ============================================================================= +// RunInfo - Contextual information about a running operation +// ============================================================================= + +// RunInfo carries metadata about the current execution context. +// This information flows through the callback chain, enabling: +// - Hierarchical tracing via parent_run_id +// - Timing measurements via start_time +// - Filtering and grouping via tags +// - Custom context via metadata +struct RunInfo { + std::string run_id; // Unique identifier for this run + std::string parent_run_id; // Parent run ID for hierarchical tracing + std::string name; // Human-readable name of the operation + std::string run_type; // Type: "chain", "tool", "llm", "graph", etc. + std::chrono::steady_clock::time_point start_time; // When execution started + std::vector tags; // Tags for filtering + core::JsonValue metadata; // Additional metadata + + RunInfo() + : start_time(std::chrono::steady_clock::now()), + metadata(core::JsonValue::object()) {} + + // Calculate duration from start to now + std::chrono::milliseconds durationMs() const { + auto now = std::chrono::steady_clock::now(); + return std::chrono::duration_cast(now - + start_time); + } +}; + +// ============================================================================= +// CallbackHandler - Interface for receiving events +// ============================================================================= + +// CallbackHandler is the base interface for all callback handlers. +// Implementations override the event methods they want to handle. +// Default implementations are provided (empty) so handlers only need +// to implement what they care about. +// +// All callback methods are called synchronously in the dispatcher thread. +// Handlers should not block or perform expensive operations. +class CallbackHandler { + public: + virtual ~CallbackHandler() = default; + + // ------------------------------------------------------------------------- + // Chain Events - Fired for Runnable chain execution + // ------------------------------------------------------------------------- + + // Called when a chain (sequence of runnables) starts execution + virtual void onChainStart(const RunInfo& info, const core::JsonValue& input) { + (void)info; + (void)input; + } + + // Called when a chain completes successfully + virtual void onChainEnd(const RunInfo& info, const core::JsonValue& output) { + (void)info; + (void)output; + } + + // Called when a chain fails with an error + virtual void onChainError(const RunInfo& info, const core::Error& error) { + (void)info; + (void)error; + } + + // ------------------------------------------------------------------------- + // Tool Events - Fired for tool/server invocations + // ------------------------------------------------------------------------- + + // Called when a tool invocation starts + virtual void onToolStart(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& input) { + (void)info; + (void)tool_name; + (void)input; + } + + // Called when a tool invocation completes successfully + virtual void onToolEnd(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& output) { + (void)info; + (void)tool_name; + (void)output; + } + + // Called when a tool invocation fails with an error + virtual void onToolError(const RunInfo& info, + const std::string& tool_name, + const core::Error& error) { + (void)info; + (void)tool_name; + (void)error; + } + + // ------------------------------------------------------------------------- + // LLM Events - For future LLM integration + // ------------------------------------------------------------------------- + + // Called when an LLM request starts + virtual void onLLMStart(const RunInfo& info, const core::JsonValue& input) { + (void)info; + (void)input; + } + + // Called when an LLM request completes + virtual void onLLMEnd(const RunInfo& info, const core::JsonValue& output) { + (void)info; + (void)output; + } + + // Called when an LLM request fails + virtual void onLLMError(const RunInfo& info, const core::Error& error) { + (void)info; + (void)error; + } + + // ------------------------------------------------------------------------- + // Custom Events - User-defined events + // ------------------------------------------------------------------------- + + // Called for user-defined custom events + // event_name: Identifies the event type (e.g., "fsm.transition") + // data: Event-specific payload + virtual void onCustomEvent(const std::string& event_name, + const core::JsonValue& data) { + (void)event_name; + (void)data; + } + + // ------------------------------------------------------------------------- + // Retry Events - For resilience pattern observability + // ------------------------------------------------------------------------- + + // Called when a retry is about to be attempted + virtual void onRetry(const RunInfo& info, + const core::Error& error, + uint32_t attempt, + uint32_t max_attempts) { + (void)info; + (void)error; + (void)attempt; + (void)max_attempts; + } +}; + +// ============================================================================= +// LoggingCallbackHandler - Logs events for debugging +// ============================================================================= + +// LoggingCallbackHandler provides a simple logging implementation. +// By default, it uses a simple stdout-based logging. In production, +// you would typically use a proper logging framework. +class LoggingCallbackHandler : public CallbackHandler { + public: + // Log level for filtering messages + enum class LogLevel { DEBUG, INFO, WARN, ERROR }; + + explicit LoggingCallbackHandler(LogLevel min_level = LogLevel::INFO) + : min_level_(min_level) {} + + void onChainStart(const RunInfo& info, + const core::JsonValue& input) override { + log(LogLevel::INFO, "CHAIN_START", info.name, input); + } + + void onChainEnd(const RunInfo& info, const core::JsonValue& output) override { + log(LogLevel::INFO, "CHAIN_END", + info.name + " (" + std::to_string(info.durationMs().count()) + "ms)", + output); + } + + void onChainError(const RunInfo& info, const core::Error& error) override { + logError(LogLevel::ERROR, "CHAIN_ERROR", info.name, error); + } + + void onToolStart(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& input) override { + log(LogLevel::INFO, "TOOL_START", tool_name, input); + } + + void onToolEnd(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& output) override { + log(LogLevel::INFO, "TOOL_END", + tool_name + " (" + std::to_string(info.durationMs().count()) + "ms)", + output); + } + + void onToolError(const RunInfo& info, + const std::string& tool_name, + const core::Error& error) override { + logError(LogLevel::ERROR, "TOOL_ERROR", tool_name, error); + } + + void onCustomEvent(const std::string& event_name, + const core::JsonValue& data) override { + log(LogLevel::DEBUG, "CUSTOM", event_name, data); + } + + void onRetry(const RunInfo& info, + const core::Error& error, + uint32_t attempt, + uint32_t max_attempts) override { + std::string msg = info.name + " attempt " + std::to_string(attempt) + "/" + + std::to_string(max_attempts); + logError(LogLevel::WARN, "RETRY", msg, error); + } + + protected: + // Override these methods to integrate with your logging framework + virtual void log(LogLevel level, + const std::string& event, + const std::string& name, + const core::JsonValue& data) { + if (level < min_level_) { + return; + } + // Simple stdout logging - replace with proper logging in production + printf("[%s] %s - %s\n", event.c_str(), name.c_str(), + data.toString().c_str()); + } + + virtual void logError(LogLevel level, + const std::string& event, + const std::string& name, + const core::Error& error) { + if (level < min_level_) { + return; + } + printf("[%s] %s - %s (code: %d)\n", event.c_str(), name.c_str(), + error.message.c_str(), error.code); + } + + private: + LogLevel min_level_; +}; + +// ============================================================================= +// NoOpCallbackHandler - Does nothing (for testing/disabling callbacks) +// ============================================================================= + +class NoOpCallbackHandler : public CallbackHandler { + public: + // All methods use default empty implementations +}; + +} // namespace callback +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/callback/callback_manager.h b/third_party/gopher-orch/include/gopher/orch/callback/callback_manager.h new file mode 100644 index 00000000..40b64ad2 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/callback/callback_manager.h @@ -0,0 +1,483 @@ +#pragma once + +// CallbackManager - Manages callback handlers and emits events +// +// The CallbackManager is responsible for: +// 1. Maintaining a collection of callback handlers +// 2. Emitting events to all registered handlers +// 3. Managing run context (run IDs, parent relationships) +// 4. Creating child managers for nested operations +// +// Usage: +// auto manager = std::make_shared(); +// manager->addHandler(std::make_shared()); +// +// // Start a chain +// auto run_info = manager->startChain("my_chain", input); +// // ... execute chain ... +// manager->endChain(run_info, output); + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/callback/callback_handler.h" +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +namespace callback { + +// ============================================================================= +// CallbackManager - Manages callback handlers +// ============================================================================= + +// CallbackManager is thread-safe and can be shared across multiple operations. +// It manages the lifecycle of run contexts and emits events to all handlers. +// +// Hierarchical tracing is supported through parent_run_id relationships: +// - When creating a child manager, the parent's run_id becomes the child's +// parent_run_id +// - This allows reconstruction of the full execution tree +class CallbackManager : public std::enable_shared_from_this { + public: + using Ptr = std::shared_ptr; + + CallbackManager() : run_id_(generateRunId()), parent_run_id_("") {} + + // ------------------------------------------------------------------------- + // Handler Management + // ------------------------------------------------------------------------- + + // Add a handler to receive events + void addHandler(std::shared_ptr handler) { + std::lock_guard lock(mutex_); + handlers_.push_back(std::move(handler)); + } + + // Remove a handler + void removeHandler(const std::shared_ptr& handler) { + std::lock_guard lock(mutex_); + handlers_.erase(std::remove(handlers_.begin(), handlers_.end(), handler), + handlers_.end()); + } + + // Get the number of registered handlers + size_t handlerCount() const { + std::lock_guard lock(mutex_); + return handlers_.size(); + } + + // Clear all handlers + void clearHandlers() { + std::lock_guard lock(mutex_); + handlers_.clear(); + } + + // ------------------------------------------------------------------------- + // Run Context Management + // ------------------------------------------------------------------------- + + // Get the current run ID + const std::string& runId() const { return run_id_; } + + // Get the parent run ID (empty if this is the root) + const std::string& parentRunId() const { return parent_run_id_; } + + // Set the parent run ID (used when creating child managers) + void setParentRunId(const std::string& parent_id) { + parent_run_id_ = parent_id; + } + + // ------------------------------------------------------------------------- + // Chain Event Emission + // ------------------------------------------------------------------------- + + // Start a chain and emit CHAIN_START event + // Returns RunInfo that should be passed to endChain/errorChain + RunInfo startChain( + const std::string& name, + const core::JsonValue& input, + const std::vector& tags = {}, + const core::JsonValue& metadata = core::JsonValue::object()) { + RunInfo info = createRunInfo(name, "chain", tags, metadata); + emitChainStart(info, input); + return info; + } + + // End a chain successfully and emit CHAIN_END event + void endChain(const RunInfo& info, const core::JsonValue& output) { + emitChainEnd(info, output); + } + + // End a chain with error and emit CHAIN_ERROR event + void errorChain(const RunInfo& info, const core::Error& error) { + emitChainError(info, error); + } + + // ------------------------------------------------------------------------- + // Tool Event Emission + // ------------------------------------------------------------------------- + + // Start a tool invocation and emit TOOL_START event + RunInfo startTool( + const std::string& tool_name, + const core::JsonValue& input, + const std::vector& tags = {}, + const core::JsonValue& metadata = core::JsonValue::object()) { + RunInfo info = createRunInfo(tool_name, "tool", tags, metadata); + emitToolStart(info, tool_name, input); + return info; + } + + // End a tool invocation successfully and emit TOOL_END event + void endTool(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& output) { + emitToolEnd(info, tool_name, output); + } + + // End a tool invocation with error and emit TOOL_ERROR event + void errorTool(const RunInfo& info, + const std::string& tool_name, + const core::Error& error) { + emitToolError(info, tool_name, error); + } + + // ------------------------------------------------------------------------- + // LLM Event Emission (for future use) + // ------------------------------------------------------------------------- + + RunInfo startLLM( + const std::string& name, + const core::JsonValue& input, + const std::vector& tags = {}, + const core::JsonValue& metadata = core::JsonValue::object()) { + RunInfo info = createRunInfo(name, "llm", tags, metadata); + emitLLMStart(info, input); + return info; + } + + void endLLM(const RunInfo& info, const core::JsonValue& output) { + emitLLMEnd(info, output); + } + + void errorLLM(const RunInfo& info, const core::Error& error) { + emitLLMError(info, error); + } + + // ------------------------------------------------------------------------- + // Direct Event Emission (lower-level API) + // ------------------------------------------------------------------------- + + void emitChainStart(const RunInfo& info, const core::JsonValue& input) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onChainStart(info, input); + } + } + + void emitChainEnd(const RunInfo& info, const core::JsonValue& output) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onChainEnd(info, output); + } + } + + void emitChainError(const RunInfo& info, const core::Error& error) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onChainError(info, error); + } + } + + void emitToolStart(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& input) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onToolStart(info, tool_name, input); + } + } + + void emitToolEnd(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& output) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onToolEnd(info, tool_name, output); + } + } + + void emitToolError(const RunInfo& info, + const std::string& tool_name, + const core::Error& error) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onToolError(info, tool_name, error); + } + } + + void emitLLMStart(const RunInfo& info, const core::JsonValue& input) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onLLMStart(info, input); + } + } + + void emitLLMEnd(const RunInfo& info, const core::JsonValue& output) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onLLMEnd(info, output); + } + } + + void emitLLMError(const RunInfo& info, const core::Error& error) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onLLMError(info, error); + } + } + + // Emit a custom event + void emitCustomEvent(const std::string& event_name, + const core::JsonValue& data) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onCustomEvent(event_name, data); + } + } + + // Emit a retry event + void emitRetry(const RunInfo& info, + const core::Error& error, + uint32_t attempt, + uint32_t max_attempts) { + std::lock_guard lock(mutex_); + for (const auto& handler : handlers_) { + handler->onRetry(info, error, attempt, max_attempts); + } + } + + // ------------------------------------------------------------------------- + // Child Manager Creation + // ------------------------------------------------------------------------- + + // Create a child manager for nested operations. + // The child inherits all handlers and sets up parent-child tracing. + // + // Usage: + // auto child = manager->child(); + // auto info = child->startChain("nested_chain", input); + // // info.parent_run_id will be set to parent's run_id + Ptr child() { + auto child_manager = std::make_shared(); + child_manager->parent_run_id_ = run_id_; + + // Copy handlers (share the same handler instances) + std::lock_guard lock(mutex_); + child_manager->handlers_ = handlers_; + + return child_manager; + } + + // Create a child manager with a specific name for the child run + Ptr childWithName(const std::string& name) { + auto child_manager = child(); + child_manager->run_name_ = name; + return child_manager; + } + + // ------------------------------------------------------------------------- + // Tag and Metadata Management + // ------------------------------------------------------------------------- + + // Add inheritable tags that will be passed to child managers + void addTags(const std::vector& tags) { + std::lock_guard lock(mutex_); + inheritable_tags_.insert(inheritable_tags_.end(), tags.begin(), tags.end()); + } + + // Add inheritable metadata that will be passed to child managers + void addMetadata(const std::string& key, const core::JsonValue& value) { + std::lock_guard lock(mutex_); + inheritable_metadata_[key] = value; + } + + // Get current inheritable tags + std::vector inheritableTags() const { + std::lock_guard lock(mutex_); + return inheritable_tags_; + } + + // Get current inheritable metadata + core::JsonValue inheritableMetadata() const { + std::lock_guard lock(mutex_); + return inheritable_metadata_; + } + + private: + // Generate a unique run ID + // Uses a simple counter + random component for uniqueness + static std::string generateRunId() { + static std::atomic counter{0}; + uint64_t count = counter.fetch_add(1); + + // Generate random component + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution dis(0, 0xFFFFFFFF); + uint32_t random_part = dis(gen); + + std::ostringstream oss; + oss << "run-" << std::hex << count << "-" << random_part; + return oss.str(); + } + + // Create a RunInfo with current context + RunInfo createRunInfo(const std::string& name, + const std::string& run_type, + const std::vector& tags, + const core::JsonValue& metadata) { + RunInfo info; + info.run_id = generateRunId(); + info.parent_run_id = parent_run_id_; + info.name = name; + info.run_type = run_type; + + // Combine inheritable tags with provided tags + { + std::lock_guard lock(mutex_); + info.tags = inheritable_tags_; + } + info.tags.insert(info.tags.end(), tags.begin(), tags.end()); + + // Merge inheritable metadata with provided metadata + info.metadata = inheritableMetadata(); + if (metadata.isObject()) { + for (auto it = metadata.begin(); it != metadata.end(); ++it) { + auto kv = *it; + info.metadata[kv.first] = kv.second; + } + } + + return info; + } + + mutable std::mutex mutex_; + std::vector> handlers_; + std::string run_id_; + std::string parent_run_id_; + std::string run_name_; + std::vector inheritable_tags_; + core::JsonValue inheritable_metadata_{core::JsonValue::object()}; +}; + +// ============================================================================= +// RAII Guard for automatic chain lifecycle management +// ============================================================================= + +// ChainGuard automatically ends a chain when it goes out of scope. +// This ensures that chain events are properly closed even if an exception +// is thrown or early return occurs. +// +// Usage: +// { +// ChainGuard guard(manager, "my_chain", input); +// // ... do work ... +// guard.setOutput(output); // Mark successful completion +// } // Automatically calls endChain or errorChain +class ChainGuard { + public: + ChainGuard(CallbackManager::Ptr manager, + const std::string& name, + const core::JsonValue& input) + : manager_(std::move(manager)), completed_(false) { + run_info_ = manager_->startChain(name, input); + } + + ~ChainGuard() { + if (!completed_) { + // If not explicitly completed, treat as error + manager_->errorChain( + run_info_, + core::Error(core::OrchError::INTERNAL_ERROR, "Chain not completed")); + } + } + + // Mark the chain as successfully completed + void setOutput(const core::JsonValue& output) { + manager_->endChain(run_info_, output); + completed_ = true; + } + + // Mark the chain as failed with an error + void setError(const core::Error& error) { + manager_->errorChain(run_info_, error); + completed_ = true; + } + + // Get the run info for this chain + const RunInfo& runInfo() const { return run_info_; } + + // Prevent copying + ChainGuard(const ChainGuard&) = delete; + ChainGuard& operator=(const ChainGuard&) = delete; + + private: + CallbackManager::Ptr manager_; + RunInfo run_info_; + bool completed_; +}; + +// ============================================================================= +// RAII Guard for automatic tool lifecycle management +// ============================================================================= + +class ToolGuard { + public: + ToolGuard(CallbackManager::Ptr manager, + const std::string& tool_name, + const core::JsonValue& input) + : manager_(std::move(manager)), tool_name_(tool_name), completed_(false) { + run_info_ = manager_->startTool(tool_name, input); + } + + ~ToolGuard() { + if (!completed_) { + manager_->errorTool( + run_info_, tool_name_, + core::Error(core::OrchError::INTERNAL_ERROR, "Tool not completed")); + } + } + + void setOutput(const core::JsonValue& output) { + manager_->endTool(run_info_, tool_name_, output); + completed_ = true; + } + + void setError(const core::Error& error) { + manager_->errorTool(run_info_, tool_name_, error); + completed_ = true; + } + + const RunInfo& runInfo() const { return run_info_; } + + ToolGuard(const ToolGuard&) = delete; + ToolGuard& operator=(const ToolGuard&) = delete; + + private: + CallbackManager::Ptr manager_; + std::string tool_name_; + RunInfo run_info_; + bool completed_; +}; + +} // namespace callback +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/composition/parallel.h b/third_party/gopher-orch/include/gopher/orch/composition/parallel.h new file mode 100644 index 00000000..fe3fb1e2 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/composition/parallel.h @@ -0,0 +1,183 @@ +#pragma once + +// Parallel - Execute multiple runnables concurrently +// Distributes the same input to all branches, collects results into a map +// +// Behavior: +// - All branches receive the same input +// - Branches execute concurrently (subject to dispatcher threading) +// - Results collected into a JSON object with branch keys +// - Fails fast: first error cancels pending branches (TODO: make configurable) + +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace composition { + +using namespace gopher::orch::core; + +// Parallel execution of JSON runnables +// Input is distributed to all branches, results collected by key +class Parallel : public JsonRunnable { + public: + using Callback = JsonRunnable::Callback; + + explicit Parallel(const std::string& name = "Parallel") : name_(name) {} + + // Add a named branch + Parallel& add(const std::string& key, JsonRunnablePtr runnable) { + branches_.emplace_back(key, std::move(runnable)); + return *this; + } + + std::string name() const override { + if (!name_.empty() && name_ != "Parallel") { + return name_; + } + if (branches_.empty()) { + return "Parallel(empty)"; + } + std::string result = "Parallel("; + for (size_t i = 0; i < branches_.size(); ++i) { + if (i > 0) + result += ", "; + result += branches_[i].first; + } + result += ")"; + return result; + } + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + if (branches_.empty()) { + // Empty parallel returns empty object + dispatcher.post([callback = std::move(callback)]() { + callback(makeSuccess(JsonValue::object())); + }); + return; + } + + // Shared state for collecting results from all branches + auto state = + std::make_shared(branches_.size(), std::move(callback)); + + // Launch all branches concurrently + for (size_t i = 0; i < branches_.size(); ++i) { + const auto& key = branches_[i].first; + const auto& runnable = branches_[i].second; + + runnable->invoke(input, config.child(), dispatcher, + [state, key, &dispatcher](Result result) { + state->onBranchComplete(key, std::move(result), + dispatcher); + }); + } + } + + // Get number of branches + size_t size() const { return branches_.size(); } + + // Check if empty + bool empty() const { return branches_.empty(); } + + private: + // State shared across all branch callbacks + struct ParallelState { + ParallelState(size_t total, Callback callback) + : remaining(total), + failed(false), + callback_(std::move(callback)), + results_(JsonValue::object()) {} + + void onBranchComplete(const std::string& key, + Result result, + Dispatcher& dispatcher) { + std::lock_guard lock(mutex_); + + // Skip if already failed (fail-fast mode) + if (failed) { + return; + } + + if (mcp::holds_alternative(result)) { + // First error triggers callback + failed = true; + // Post to dispatcher to ensure callback runs in dispatcher context + auto cb = std::move(callback_); + auto error = mcp::get(result); + dispatcher.post( + [cb = std::move(cb), error]() { cb(Result(error)); }); + return; + } + + // Store successful result + results_[key] = mcp::get(result); + remaining--; + + if (remaining == 0) { + // All branches completed successfully + auto cb = std::move(callback_); + auto results = std::move(results_); + dispatcher.post([cb = std::move(cb), results = std::move(results)]() { + cb(makeSuccess(std::move(results))); + }); + } + } + + std::mutex mutex_; + size_t remaining; + bool failed; + Callback callback_; + JsonValue results_; + }; + + std::vector> branches_; + std::string name_; +}; + +// Builder for creating Parallel with fluent API +class ParallelBuilder { + public: + explicit ParallelBuilder(const std::string& name = "Parallel") + : parallel_(std::make_shared(name)) {} + + ParallelBuilder& add(const std::string& key, JsonRunnablePtr runnable) { + parallel_->add(key, std::move(runnable)); + return *this; + } + + // Template version for typed runnables + template + ParallelBuilder& add(const std::string& key, std::shared_ptr runnable) { + parallel_->add(key, + std::static_pointer_cast(std::move(runnable))); + return *this; + } + + std::shared_ptr build() { return std::move(parallel_); } + + // Implicit conversion to shared_ptr + operator std::shared_ptr() { return build(); } + + private: + std::shared_ptr parallel_; +}; + +// Factory for Parallel +inline ParallelBuilder parallel(const std::string& name = "Parallel") { + return ParallelBuilder(name); +} + +} // namespace composition +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/composition/router.h b/third_party/gopher-orch/include/gopher/orch/composition/router.h new file mode 100644 index 00000000..31dc45f6 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/composition/router.h @@ -0,0 +1,147 @@ +#pragma once + +// Router - Conditional branching for runnables +// Routes input to different runnables based on conditions +// +// Behavior: +// - Evaluates conditions in order until one matches +// - Routes to the matching runnable +// - Falls back to default if no condition matches +// - Returns error if no match and no default + +#include +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace composition { + +using namespace gopher::orch::core; + +// Type-safe Router for typed runnables +// Evaluates conditions against input and routes to matching runnable +template +class Router : public Runnable { + public: + using Condition = std::function; + using RunnablePtr = std::shared_ptr>; + using Route = std::pair; + using Callback = typename Runnable::Callback; + + Router(std::vector routes, + RunnablePtr default_route, + const std::string& name = "") + : routes_(std::move(routes)), + default_(std::move(default_route)), + name_(name) {} + + std::string name() const override { + if (!name_.empty()) { + return name_; + } + std::string result = "Router("; + result += std::to_string(routes_.size()) + " routes"; + if (default_) { + result += ", default=" + default_->name(); + } + result += ")"; + return result; + } + + void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Evaluate conditions in order + for (const auto& route : routes_) { + if (route.first(input)) { + // Found matching route - invoke it + route.second->invoke(input, config.child(), dispatcher, + std::move(callback)); + return; + } + } + + // No match - try default route + if (default_) { + default_->invoke(input, config.child(), dispatcher, std::move(callback)); + return; + } + + // No match and no default - return error + dispatcher.post([callback = std::move(callback)]() { + callback(makeOrchError(OrchError::INVALID_ARGUMENT, + "No matching route and no default")); + }); + } + + // Get number of routes + size_t size() const { return routes_.size(); } + + // Check if has default route + bool hasDefault() const { return default_ != nullptr; } + + private: + std::vector routes_; + RunnablePtr default_; + std::string name_; +}; + +// JSON Router - type-erased version for dynamic routing +using JsonRouter = Router; + +// Builder for creating Router with fluent API +template +class RouterBuilder { + public: + using Condition = std::function; + using RunnablePtr = std::shared_ptr>; + + explicit RouterBuilder(const std::string& name = "") : name_(name) {} + + // Add a conditional route + RouterBuilder& when(Condition condition, RunnablePtr runnable) { + routes_.emplace_back(std::move(condition), std::move(runnable)); + return *this; + } + + // Set default route (when no conditions match) + RouterBuilder& otherwise(RunnablePtr runnable) { + default_ = std::move(runnable); + return *this; + } + + std::shared_ptr> build() { + return std::make_shared>(std::move(routes_), + std::move(default_), name_); + } + + // Implicit conversion to shared_ptr + operator std::shared_ptr>() { return build(); } + + private: + std::vector> routes_; + RunnablePtr default_; + std::string name_; +}; + +// Factory for JSON router builder +inline RouterBuilder router( + const std::string& name = "") { + return RouterBuilder(name); +} + +// Factory function for type-safe router +template +RouterBuilder makeRouter(const std::string& name = "") { + return RouterBuilder(name); +} + +} // namespace composition +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/composition/sequence.h b/third_party/gopher-orch/include/gopher/orch/composition/sequence.h new file mode 100644 index 00000000..3327b7a8 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/composition/sequence.h @@ -0,0 +1,207 @@ +#pragma once + +// Sequence - Chain runnables together: output of one becomes input of next +// Implements the pipe pattern: A | B | C means A.output -> B.input -> C.input +// +// Short-circuits on first error - subsequent steps are not executed + +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace composition { + +using namespace gopher::orch::core; + +// Sequence of two runnables with type-safe chaining +// A's output must match B's input type +template +class Sequence2 : public Runnable { + public: + using FirstPtr = std::shared_ptr>; + using SecondPtr = std::shared_ptr>; + using Callback = typename Runnable::Callback; + + Sequence2(FirstPtr first, SecondPtr second, const std::string& name = "") + : first_(std::move(first)), + second_(std::move(second)), + name_(name.empty() ? first_->name() + " | " + second_->name() : name) {} + + std::string name() const override { return name_; } + + void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Capture pointers by value to extend lifetime + auto first = first_; + auto second = second_; + + // Invoke first, then chain to second on success + first->invoke(input, config, dispatcher, + [second, config, &dispatcher, callback = std::move(callback)]( + Result result) mutable { + if (mcp::holds_alternative(result)) { + // Short-circuit: propagate error without running second + callback(Result(mcp::get(result))); + } else { + // Chain: use first's output as second's input + second->invoke(mcp::get(result), config.child(), + dispatcher, std::move(callback)); + } + }); + } + + private: + FirstPtr first_; + SecondPtr second_; + std::string name_; +}; + +// JSON Sequence - chains multiple JSON runnables +// Uses type-erased JsonRunnable for dynamic composition +class Sequence : public JsonRunnable { + public: + using Callback = JsonRunnable::Callback; + + explicit Sequence(const std::string& name = "Sequence") : name_(name) {} + + // Add a step to the sequence + Sequence& add(JsonRunnablePtr step) { + steps_.push_back(std::move(step)); + return *this; + } + + // Build the sequence name from step names if not explicitly set + std::string name() const override { + if (!name_.empty() && name_ != "Sequence") { + return name_; + } + if (steps_.empty()) { + return "Sequence(empty)"; + } + std::string result = steps_[0]->name(); + for (size_t i = 1; i < steps_.size(); ++i) { + result += " | " + steps_[i]->name(); + } + return result; + } + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + if (steps_.empty()) { + // Empty sequence just passes through input + dispatcher.post([input, callback = std::move(callback)]() { + callback(makeSuccess(input)); + }); + return; + } + + // Start the chain with first step + invokeStep(0, input, config, dispatcher, std::move(callback)); + } + + // Get number of steps + size_t size() const { return steps_.size(); } + + // Check if empty + bool empty() const { return steps_.empty(); } + + private: + void invokeStep(size_t index, + const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) { + if (index >= steps_.size()) { + // All steps completed successfully + dispatcher.post([input, callback = std::move(callback)]() { + callback(makeSuccess(input)); + }); + return; + } + + // Capture state for the callback chain + // Using shared_from_this to keep the Sequence alive during async execution + auto self = std::static_pointer_cast(this->shared_from_this()); + auto step = steps_[index]; + + step->invoke( + input, config.child(), dispatcher, + [self, index, config, &dispatcher, + callback = std::move(callback)](Result result) mutable { + if (mcp::holds_alternative(result)) { + // Short-circuit on error + callback(std::move(result)); + } else { + // Continue to next step + self->invokeStep(index + 1, mcp::get(result), config, + dispatcher, std::move(callback)); + } + }); + } + + std::vector steps_; + std::string name_; +}; + +// Builder for creating Sequence with fluent API +class SequenceBuilder { + public: + explicit SequenceBuilder(const std::string& name = "Sequence") + : sequence_(std::make_shared(name)) {} + + SequenceBuilder& add(JsonRunnablePtr step) { + sequence_->add(std::move(step)); + return *this; + } + + // Template version for typed runnables + template + SequenceBuilder& add(std::shared_ptr step) { + sequence_->add(std::static_pointer_cast(std::move(step))); + return *this; + } + + std::shared_ptr build() { return std::move(sequence_); } + + // Implicit conversion to shared_ptr + operator std::shared_ptr() { return build(); } + + private: + std::shared_ptr sequence_; +}; + +// Factory function for type-safe two-step sequence +template +std::shared_ptr> makeSequence( + std::shared_ptr> first, + std::shared_ptr> second, + const std::string& name = "") { + return std::make_shared>(std::move(first), + std::move(second), name); +} + +// Operator | for chaining (type-safe version) +template +std::shared_ptr> operator|( + std::shared_ptr> first, + std::shared_ptr> second) { + return makeSequence(std::move(first), std::move(second)); +} + +// Factory for JSON sequence +inline SequenceBuilder sequence(const std::string& name = "Sequence") { + return SequenceBuilder(name); +} + +} // namespace composition +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/core/config.h b/third_party/gopher-orch/include/gopher/orch/core/config.h new file mode 100644 index 00000000..e3906930 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/core/config.h @@ -0,0 +1,145 @@ +#pragma once + +// RunnableConfig - Configuration options for Runnable invocations +// Provides metadata, tags, and execution options that flow through the chain + +#include +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { + +// Forward declaration for CallbackManager (avoids circular dependency) +namespace callback { +class CallbackManager; +} // namespace callback + +namespace core { + +// Configuration passed to each Runnable invocation +// Carries metadata, tags, and execution options through the composition chain +class RunnableConfig { + public: + RunnableConfig() = default; + + // Builder pattern for fluent configuration + RunnableConfig& withTag(const std::string& key, const std::string& value) { + tags_[key] = value; + return *this; + } + + RunnableConfig& withMetadata(const std::string& key, const JsonValue& value) { + metadata_[key] = value; + return *this; + } + + RunnableConfig& withRunName(const std::string& name) { + run_name_ = name; + return *this; + } + + RunnableConfig& withMaxConcurrency(size_t max) { + max_concurrency_ = max; + return *this; + } + + RunnableConfig& withTimeout(std::chrono::milliseconds timeout) { + timeout_ms_ = timeout; + return *this; + } + + RunnableConfig& withRecursionLimit(size_t limit) { + recursion_limit_ = limit; + return *this; + } + + // Set the callback manager for observability + RunnableConfig& withCallbacks( + std::shared_ptr callbacks) { + callbacks_ = std::move(callbacks); + return *this; + } + + // Accessors + const std::map& tags() const { return tags_; } + + const std::map& metadata() const { return metadata_; } + + optional tag(const std::string& key) const { + auto it = tags_.find(key); + if (it != tags_.end()) { + return mcp::make_optional(it->second); + } + return nullopt; + } + + const std::string& runName() const { return run_name_; } + + size_t maxConcurrency() const { return max_concurrency_; } + + std::chrono::milliseconds timeout() const { return timeout_ms_; } + + size_t recursionLimit() const { return recursion_limit_; } + + // Get the callback manager (may be null) + std::shared_ptr callbacks() const { + return callbacks_; + } + + // Check if callbacks are configured + bool hasCallbacks() const { return callbacks_ != nullptr; } + + // Merge another config into this one (other takes precedence) + RunnableConfig& merge(const RunnableConfig& other) { + for (const auto& kv : other.tags_) { + tags_[kv.first] = kv.second; + } + for (const auto& kv : other.metadata_) { + metadata_[kv.first] = kv.second; + } + if (!other.run_name_.empty()) { + run_name_ = other.run_name_; + } + if (other.max_concurrency_ > 0) { + max_concurrency_ = other.max_concurrency_; + } + if (other.timeout_ms_.count() > 0) { + timeout_ms_ = other.timeout_ms_; + } + if (other.recursion_limit_ > 0) { + recursion_limit_ = other.recursion_limit_; + } + if (other.callbacks_) { + callbacks_ = other.callbacks_; + } + return *this; + } + + // Create a child config that inherits from this config + RunnableConfig child() const { + RunnableConfig child_config = *this; + // Decrement recursion limit for child + if (child_config.recursion_limit_ > 0) { + child_config.recursion_limit_--; + } + return child_config; + } + + private: + std::map tags_; + std::map metadata_; + std::string run_name_; + size_t max_concurrency_ = 0; // 0 means unlimited + std::chrono::milliseconds timeout_ms_{0}; // 0 means no timeout + size_t recursion_limit_ = 25; // Default recursion limit + std::shared_ptr callbacks_; // Observability hooks +}; + +} // namespace core +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/core/lambda.h b/third_party/gopher-orch/include/gopher/orch/core/lambda.h new file mode 100644 index 00000000..5cda34c2 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/core/lambda.h @@ -0,0 +1,145 @@ +#pragma once + +// Lambda - Create Runnable from a function or lambda +// Enables quick creation of custom operations without defining new classes + +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace core { + +// Synchronous function signature: (Input, Config) -> Result +// Use this when the operation can complete immediately +template +using SyncFunc = + std::function(const Input&, const RunnableConfig&)>; + +// Asynchronous function signature: (Input, Config, Dispatcher&, Callback) +// Use this when the operation needs async I/O or timer-based delays +template +using AsyncFunc = std::function)>; + +// Lambda Runnable - wraps a function as a Runnable +// +// Supports both synchronous and asynchronous functions: +// - Sync functions are posted to dispatcher for execution +// - Async functions are called directly (they manage their own posting) +template +class Lambda : public Runnable { + public: + using Callback = typename Runnable::Callback; + + // Create from synchronous function + // The function will be invoked via dispatcher.post() to ensure + // the callback runs in dispatcher context + static std::shared_ptr fromSync(SyncFunc func, + const std::string& name = "Lambda") { + return std::shared_ptr(new Lambda(std::move(func), name, true)); + } + + // Create from asynchronous function + // The function is responsible for calling the callback in dispatcher context + static std::shared_ptr fromAsync(AsyncFunc func, + const std::string& name = "Lambda") { + return std::shared_ptr(new Lambda(std::move(func), name, false)); + } + + std::string name() const override { return name_; } + + void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + if (is_sync_) { + // For sync functions, post to dispatcher to ensure callback runs in + // dispatcher context. Capture by value to ensure data survives + auto func = sync_func_; + dispatcher.post( + [func, input, config, callback = std::move(callback)]() mutable { + Result result = func(input, config); + callback(std::move(result)); + }); + } else { + // For async functions, call directly - they manage their own posting + async_func_(input, config, dispatcher, std::move(callback)); + } + } + + private: + // Private constructor - use factory methods + Lambda(SyncFunc func, std::string name, bool is_sync) + : sync_func_(std::move(func)), + name_(std::move(name)), + is_sync_(is_sync) {} + + Lambda(AsyncFunc func, std::string name, bool is_sync) + : async_func_(std::move(func)), + name_(std::move(name)), + is_sync_(is_sync) {} + + SyncFunc sync_func_; + AsyncFunc async_func_; + std::string name_; + bool is_sync_; +}; + +// Convenience factory functions + +// Create Lambda from sync function: (Input, Config) -> Result +template +std::shared_ptr> makeLambda( + SyncFunc func, const std::string& name = "Lambda") { + return Lambda::fromSync(std::move(func), name); +} + +// Create Lambda from simple sync function: Input -> Result +// (ignores config) +template +std::shared_ptr> makeLambda( + std::function(const Input&)> func, + const std::string& name = "Lambda") { + return Lambda::fromSync( + [func = std::move(func)](const Input& input, const RunnableConfig&) { + return func(input); + }, + name); +} + +// Create Lambda from async function +template +std::shared_ptr> makeLambdaAsync( + AsyncFunc func, const std::string& name = "Lambda") { + return Lambda::fromAsync(std::move(func), name); +} + +// JSON-specific Lambda (most common use case for FFI and dynamic composition) +using JsonLambda = Lambda; + +// Create JSON Lambda from sync function +inline std::shared_ptr makeJsonLambda( + SyncFunc func, + const std::string& name = "JsonLambda") { + return JsonLambda::fromSync(std::move(func), name); +} + +// Create JSON Lambda from simple sync function (ignores config) +inline std::shared_ptr makeJsonLambda( + std::function(const JsonValue&)> func, + const std::string& name = "JsonLambda") { + return JsonLambda::fromSync( + [func = std::move(func)](const JsonValue& input, const RunnableConfig&) { + return func(input); + }, + name); +} + +} // namespace core +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/core/runnable.h b/third_party/gopher-orch/include/gopher/orch/core/runnable.h new file mode 100644 index 00000000..8040caa4 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/core/runnable.h @@ -0,0 +1,115 @@ +#pragma once + +// Runnable - Universal composable interface +// Core abstraction for all operations in the orchestration framework +// +// Design principles: +// - Async-first: All operations use callbacks, no blocking +// - Dispatcher-native: Callbacks invoked in dispatcher thread context +// - Composable: Can be chained with pipe(), parallel(), etc. +// - Type-safe: Strong typing with explicit Input/Output types + +#include +#include + +#include "gopher/orch/core/config.h" +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +namespace core { + +// Forward declarations for composition functions +template +class SequenceRunnable; + +template +class ParallelRunnable; + +// Runnable - Base class for all composable operations +// +// All callbacks are invoked in dispatcher thread context following the pattern: +// Create -> Configure -> Invoke (with dispatcher) -> Callback in dispatcher +// +// Implementations must: +// 1. Call callback exactly once (success or error) +// 2. Post callback to dispatcher if not already in dispatcher context +// 3. Handle cancellation gracefully +template +class Runnable : public std::enable_shared_from_this> { + public: + using InputType = Input; + using OutputType = Output; + using Callback = ResultCallback; + using Ptr = std::shared_ptr>; + + virtual ~Runnable() = default; + + // Human-readable name for debugging and tracing + virtual std::string name() const = 0; + + // Invoke the runnable asynchronously + // - input: The input value to process + // - config: Configuration options (tags, metadata, timeout, etc.) + // - dispatcher: Event loop for async operations + // - callback: Called exactly once with Result + // + // The callback MUST be invoked in the dispatcher's thread context. + // Implementations should post to dispatcher if running in a different thread. + virtual void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) = 0; + + // Convenience: invoke with default config + void invoke(const Input& input, Dispatcher& dispatcher, Callback callback) { + invoke(input, RunnableConfig(), dispatcher, std::move(callback)); + } + + // Get shared pointer to this runnable + Ptr shared() { return this->shared_from_this(); } + + protected: + Runnable() = default; + + // Helper to post callback to dispatcher + // Use this when the result is ready but we're not in dispatcher context + template + static void postResult(Dispatcher& dispatcher, + ResultCallback callback, + Result result) { + dispatcher.post( + [callback = std::move(callback), result = std::move(result)]() mutable { + callback(std::move(result)); + }); + } + + // Helper to post error to dispatcher + template + static void postError(Dispatcher& dispatcher, + ResultCallback callback, + int code, + const std::string& message) { + dispatcher.post([callback = std::move(callback), code, message]() { + callback(Result(Error(code, message))); + }); + } +}; + +// Type alias for JSON-to-JSON runnable (used for type-erased operations) +using JsonRunnable = Runnable; +using JsonRunnablePtr = std::shared_ptr; + +// Concept-like trait to check if a type is a Runnable +template +struct is_runnable : std::false_type {}; + +template +struct is_runnable> : std::true_type {}; + +template +struct is_runnable>> : std::true_type {}; + +} // namespace core +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/core/types.h b/third_party/gopher-orch/include/gopher/orch/core/types.h new file mode 100644 index 00000000..99f17bf1 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/core/types.h @@ -0,0 +1,121 @@ +#pragma once + +// Core types for gopher-orch framework +// Provides type aliases and common definitions used throughout the library + +#include +#include +#include // Required for placement new in variant +#include +#include + +// Use MCP core types - compat.h handles C++14/17 compatibility +#include "mcp/core/compat.h" +#include "mcp/core/result.h" +#include "mcp/core/type_helpers.h" +#include "mcp/event/libevent_dispatcher.h" +#include "mcp/json/json_bridge.h" +#include "mcp/types.h" + +namespace gopher { +namespace orch { +namespace core { + +// Re-export MCP types into our namespace for convenience +using mcp::Error; +using mcp::make_optional; +using mcp::nullopt; +using mcp::optional; +using mcp::Result; + +// JSON type alias - using MCP's JsonValue +using JsonValue = mcp::json::JsonValue; + +// Dispatcher type from MCP event system +using Dispatcher = mcp::event::Dispatcher; +using DispatcherPtr = std::unique_ptr; + +// Result callback type - invoked when async operation completes +// All callbacks are invoked in dispatcher thread context +template +using ResultCallback = std::function)>; + +// Void result for operations that don't return a value +using VoidResult = Result; +using VoidCallback = ResultCallback; + +// JSON-specific callback used for type-erased operations +using JsonCallback = ResultCallback; + +// Forward declarations +template +class Runnable; + +class RunnableConfig; + +// Type-erased runnable that works with JSON values +// This is the primary interface used by composition patterns and FFI +using JsonRunnable = Runnable; +using JsonRunnablePtr = std::shared_ptr; + +// Error codes specific to orchestration +// Using enum for C++14 compatibility (constexpr static members need out-of-line +// definition) +namespace OrchError { +enum : int { + OK = 0, + INVALID_ARGUMENT = -1, + TOOL_NOT_FOUND = -2, + CONNECTION_FAILED = -3, + TIMEOUT = -4, + CANCELLED = -5, + GUARD_REJECTED = -6, + INVALID_TRANSITION = -7, + APPROVAL_DENIED = -8, + CIRCUIT_OPEN = -9, + FALLBACK_EXHAUSTED = -10, + NOT_CONNECTED = -11, + INTERNAL_ERROR = -99 +}; +} // namespace OrchError + +// Helper to create error results +template +inline Result makeOrchError(int code, const std::string& message) { + return Result(Error(code, message)); +} + +// Helper to create success results +// Uses decay to remove const/reference qualifiers for proper Result type +template +inline Result::type> makeSuccess(T&& value) { + return Result::type>(std::forward(value)); +} + +// Helper to check if result is successful +template +inline bool isSuccess(const Result& result) { + return mcp::holds_alternative(result); +} + +// Helper to check if result is an error +template +inline bool isError(const Result& result) { + return mcp::holds_alternative(result); +} + +// Helper to get value from result (undefined behavior if error) +template +inline const T& getValue(const Result& result) { + return mcp::get(result); +} + +// Helper to get error from result (undefined behavior if success) +template +inline const Error& getError(const Result& result) { + return mcp::get(result); +} + +} // namespace core +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi.h b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi.h new file mode 100644 index 00000000..ca6b9e4d --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi.h @@ -0,0 +1,1406 @@ +/** + * @file orch_ffi.h + * @brief FFI-friendly C API for gopher-orch orchestration framework + * + * This header provides the complete C API for the gopher-orch C++ framework. + * It follows an event-driven, dispatcher thread-confined architecture while + * ensuring FFI-safety and automatic resource management through RAII. + * + * Architecture: + * - All operations happen in dispatcher thread context + * - Callbacks are invoked in dispatcher thread + * - RAII guards ensure automatic cleanup + * - FFI-safe types for cross-language bindings + * - Follows Create -> Configure -> Use -> Destroy lifecycle + * + * Memory Management: + * - All handles are reference-counted internally + * - Automatic cleanup through RAII guards + * - Optional manual resource management for FFI + * - Thread-safe resource tracking in debug mode + * + * Key Design Decision - JSON-to-JSON FFI Boundary: + * - All Runnable templates are type-erased to JSON->JSON + * - This provides the cleanest FFI surface (80% of use cases) + * - Target languages handle typing in their wrapper layers + * - For custom types, use JSON serialization at the boundary + * + * Usage from other languages: + * - Python: ctypes/cffi wrapper, or pybind11 for direct C++ binding + * - Node.js: nbind or N-API native addon + * - Rust: cxx crate or bindgen for C API + * - Go: cgo with C API + * - Ruby: Rice gem (pybind11-like) or FFI gem + * - Lua: sol2 or LuaBridge + */ + +#ifndef GOPHER_ORCH_FFI_H +#define GOPHER_ORCH_FFI_H + +#include "orch_ffi_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ============================================================================ + * Version and Initialization + * ============================================================================ + */ + +#define GOPHER_ORCH_VERSION_MAJOR 1 +#define GOPHER_ORCH_VERSION_MINOR 0 +#define GOPHER_ORCH_VERSION_PATCH 0 + +/** + * Get runtime version (for ABI compatibility check) + * Caller should verify version matches compiled headers + */ +GOPHER_ORCH_API void gopher_orch_version(int* major, + int* minor, + int* patch) GOPHER_ORCH_NOEXCEPT; + +/** + * Get version as string + * @return Version string (e.g., "1.0.0"), do not free + */ +GOPHER_ORCH_API const char* gopher_orch_version_string(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Initialize library (call once at startup) + * Must be called before any other API functions + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_init(void) GOPHER_ORCH_NOEXCEPT; + +/** + * Shutdown library (call once at shutdown) + * Cleans up all resources and checks for leaks + */ +GOPHER_ORCH_API void gopher_orch_shutdown(void) GOPHER_ORCH_NOEXCEPT; + +/** + * Check if library is initialized + * @return GOPHER_ORCH_TRUE if initialized + */ +GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_is_initialized(void) + GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Error Handling + * ============================================================================ + */ + +/** + * Get last error info for current thread + * @return Error info struct, or NULL if no error + */ +GOPHER_ORCH_API const gopher_orch_error_info_t* gopher_orch_last_error(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Get human-readable error name + * @param code Error code + * @return Error name string (e.g., "GOPHER_ORCH_ERROR_TIMEOUT"), do not free + */ +GOPHER_ORCH_API const char* gopher_orch_error_name(gopher_orch_error_t code) + GOPHER_ORCH_NOEXCEPT; + +/** + * Clear last error for current thread + */ +GOPHER_ORCH_API void gopher_orch_clear_error(void) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Memory Management + * ============================================================================ + */ + +/** + * Free memory allocated by the library + * Use for strings returned with OWNED semantics + * @param ptr Pointer to free (NULL-safe) + */ +GOPHER_ORCH_API void gopher_orch_free(void* ptr) GOPHER_ORCH_NOEXCEPT; + +/** + * Free string buffer + * @param buffer String buffer to free (NULL-safe) + */ +GOPHER_ORCH_API void gopher_orch_string_buffer_free( + gopher_orch_string_buffer_t* buffer) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * RAII Guard Functions + * + * Guards provide automatic cleanup when resources go out of scope. + * This pattern works well with FFI - caller creates guard, performs + * operations, then either commits (takes ownership) or lets guard cleanup. + * ============================================================================ + */ + +/** + * Create a RAII guard for a handle with automatic cleanup + * @param handle Handle to guard (takes ownership) + * @param type Type of handle for validation + * @return Guard handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_guard_t gopher_orch_guard_create( + void* handle, gopher_orch_type_id_t type) GOPHER_ORCH_NOEXCEPT; + +/** + * Create a RAII guard with custom cleanup function + * @param handle Handle to guard (takes ownership) + * @param type Type of handle for validation + * @param cleanup Custom cleanup function + * @return Guard handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_guard_t gopher_orch_guard_create_custom( + void* handle, + gopher_orch_type_id_t type, + gopher_orch_cleanup_fn cleanup) GOPHER_ORCH_NOEXCEPT; + +/** + * Release resource from guard (prevents automatic cleanup) + * @param guard Guard handle (will be nullified) + * @return Original handle (caller takes ownership) + */ +GOPHER_ORCH_API void* gopher_orch_guard_release(gopher_orch_guard_t* guard) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy guard and cleanup resource + * @param guard Guard handle (will be nullified) + */ +GOPHER_ORCH_API void gopher_orch_guard_destroy(gopher_orch_guard_t* guard) + GOPHER_ORCH_NOEXCEPT; + +/** + * Check if guard is valid and holds a resource + * @param guard Guard handle + * @return GOPHER_ORCH_TRUE if valid + */ +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_guard_is_valid(gopher_orch_guard_t guard) GOPHER_ORCH_NOEXCEPT; + +/** + * Get the guarded resource without releasing ownership + * @param guard Guard handle + * @return Guarded resource or NULL + */ +GOPHER_ORCH_API void* gopher_orch_guard_get(gopher_orch_guard_t guard) + GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Transaction Management + * + * Transactions ensure all-or-nothing semantics for multi-resource operations. + * Use when creating multiple resources that depend on each other. + * ============================================================================ + */ + +/** + * Create a new transaction with default options + * @return Transaction handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_transaction_t gopher_orch_transaction_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Create a new transaction with custom options + * @param opts Transaction options (may be NULL for defaults) + * @return Transaction handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_transaction_t gopher_orch_transaction_create_ex( + const gopher_orch_transaction_opts_t* opts) GOPHER_ORCH_NOEXCEPT; + +/** + * Add resource to transaction with automatic cleanup + * @param txn Transaction handle + * @param handle Resource handle (ownership transferred) + * @param type Resource type for validation + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_transaction_add(gopher_orch_transaction_t txn, + void* handle, + gopher_orch_type_id_t type) GOPHER_ORCH_NOEXCEPT; + +/** + * Commit transaction (release resources, prevent cleanup) + * @param txn Transaction handle (will be nullified) + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_transaction_commit( + gopher_orch_transaction_t* txn) GOPHER_ORCH_NOEXCEPT; + +/** + * Rollback transaction (cleanup all resources) + * @param txn Transaction handle (will be nullified) + */ +GOPHER_ORCH_API void gopher_orch_transaction_rollback( + gopher_orch_transaction_t* txn) GOPHER_ORCH_NOEXCEPT; + +/** + * Get number of resources in transaction + * @param txn Transaction handle + * @return Number of resources + */ +GOPHER_ORCH_API gopher_orch_size_t gopher_orch_transaction_size( + gopher_orch_transaction_t txn) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Cancellation Token + * + * Tokens allow cancelling async operations from any thread. + * ============================================================================ + */ + +/** + * Create cancellation token + * @return Token handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_cancel_token_t gopher_orch_cancel_token_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy cancellation token + * @param token Token handle + */ +GOPHER_ORCH_API void gopher_orch_cancel_token_destroy( + gopher_orch_cancel_token_t token) GOPHER_ORCH_NOEXCEPT; + +/** + * Request cancellation - safe to call from any thread + * @param token Token handle + */ +GOPHER_ORCH_API void gopher_orch_cancel_token_cancel( + gopher_orch_cancel_token_t token) GOPHER_ORCH_NOEXCEPT; + +/** + * Check if cancelled + * @param token Token handle + * @return GOPHER_ORCH_TRUE if cancelled + */ +GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_cancel_token_is_cancelled( + gopher_orch_cancel_token_t token) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Dispatcher (Event Loop) + * + * The dispatcher provides an event loop for async operations. + * All callbacks are invoked in the dispatcher thread context. + * ============================================================================ + */ + +/** + * Create dispatcher + * @return Dispatcher handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_dispatcher_t gopher_orch_dispatcher_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Create dispatcher with RAII guard + * @param guard Output: RAII guard for automatic cleanup + * @return Dispatcher handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_dispatcher_t gopher_orch_dispatcher_create_guarded( + gopher_orch_guard_t* guard) GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy dispatcher + * @param dispatcher Dispatcher handle + */ +GOPHER_ORCH_API void gopher_orch_dispatcher_destroy( + gopher_orch_dispatcher_t dispatcher) GOPHER_ORCH_NOEXCEPT; + +/** + * Run dispatcher (blocks until stopped) + * @param dispatcher Dispatcher handle + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_dispatcher_run( + gopher_orch_dispatcher_t dispatcher) GOPHER_ORCH_NOEXCEPT; + +/** + * Run dispatcher for one iteration + * @param dispatcher Dispatcher handle + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_dispatcher_run_one( + gopher_orch_dispatcher_t dispatcher) GOPHER_ORCH_NOEXCEPT; + +/** + * Run dispatcher for specified duration + * @param dispatcher Dispatcher handle + * @param timeout_ms Maximum time in milliseconds + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_dispatcher_run_timeout(gopher_orch_dispatcher_t dispatcher, + uint64_t timeout_ms) GOPHER_ORCH_NOEXCEPT; + +/** + * Stop dispatcher + * @param dispatcher Dispatcher handle + */ +GOPHER_ORCH_API void gopher_orch_dispatcher_stop( + gopher_orch_dispatcher_t dispatcher) GOPHER_ORCH_NOEXCEPT; + +/** + * Post work to dispatcher thread + * @param dispatcher Dispatcher handle + * @param work Work function to execute + * @param user_context User context passed to work function + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_dispatcher_post(gopher_orch_dispatcher_t dispatcher, + gopher_orch_work_fn work, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Check if current thread is dispatcher thread + * @param dispatcher Dispatcher handle + * @return GOPHER_ORCH_TRUE if in dispatcher thread + */ +GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_dispatcher_is_thread( + gopher_orch_dispatcher_t dispatcher) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * JSON Value API + * + * JSON is the primary data type at the FFI boundary. + * All complex data is passed as JSON values. + * ============================================================================ + */ + +/* Creation */ +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_null(void) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_json_t +gopher_orch_json_bool(gopher_orch_bool_t value) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_int(int64_t value) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_double(double value) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_string(const char* value) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_object(void) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_array(void) + GOPHER_ORCH_NOEXCEPT; + +/* Lifecycle - reference counting */ +GOPHER_ORCH_API void gopher_orch_json_add_ref(gopher_orch_json_t handle) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API void gopher_orch_json_release(gopher_orch_json_t handle) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_json_t +gopher_orch_json_clone(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; + +/* Object operations */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_json_set( + gopher_orch_json_t obj, const char* key, gopher_orch_json_t value) + GOPHER_ORCH_NOEXCEPT; /* Takes ownership of value */ + +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_get(gopher_orch_json_t obj, + const char* key) + GOPHER_ORCH_NOEXCEPT; /* Returns BORROWED reference */ + +GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_json_has( + gopher_orch_json_t obj, const char* key) GOPHER_ORCH_NOEXCEPT; + +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_json_remove( + gopher_orch_json_t obj, const char* key) GOPHER_ORCH_NOEXCEPT; + +/* Array operations */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_json_push(gopher_orch_json_t arr, gopher_orch_json_t value) + GOPHER_ORCH_NOEXCEPT; /* Takes ownership of value */ + +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_at(gopher_orch_json_t arr, + gopher_orch_size_t index) + GOPHER_ORCH_NOEXCEPT; /* Returns BORROWED reference */ + +GOPHER_ORCH_API gopher_orch_size_t +gopher_orch_json_length(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; + +/* Type checking */ +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_json_is_null(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_json_is_bool(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_json_is_number(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_json_is_string(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_json_is_object(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_json_is_array(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; + +/* Value extraction */ +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_json_as_bool(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API int64_t gopher_orch_json_as_int(gopher_orch_json_t handle) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API double gopher_orch_json_as_double(gopher_orch_json_t handle) + GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API const char* gopher_orch_json_as_string( + gopher_orch_json_t handle) + GOPHER_ORCH_NOEXCEPT; /* Returns BORROWED string */ + +/* Serialization */ +GOPHER_ORCH_API char* gopher_orch_json_stringify(gopher_orch_json_t handle) + GOPHER_ORCH_NOEXCEPT; /* OWNED: Caller must gopher_orch_free() */ + +GOPHER_ORCH_API char* gopher_orch_json_stringify_pretty( + gopher_orch_json_t handle) + GOPHER_ORCH_NOEXCEPT; /* OWNED: Caller must gopher_orch_free() */ + +GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_parse(const char* json_str) + GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * JSON Iterator API + * + * Iterate over object keys and array elements. + * ============================================================================ + */ + +/** + * Create iterator for JSON object or array + * @param handle JSON object or array handle + * @return Iterator handle or NULL + */ +GOPHER_ORCH_API gopher_orch_iterator_t +gopher_orch_json_iter(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy iterator + * @param iter Iterator handle + */ +GOPHER_ORCH_API void gopher_orch_iter_destroy(gopher_orch_iterator_t iter) + GOPHER_ORCH_NOEXCEPT; + +/** + * Advance to next element + * @param iter Iterator handle + * @return GOPHER_ORCH_TRUE if advanced, GOPHER_ORCH_FALSE if exhausted + */ +GOPHER_ORCH_API gopher_orch_bool_t +gopher_orch_iter_next(gopher_orch_iterator_t iter) GOPHER_ORCH_NOEXCEPT; + +/** + * Get current key (for object iterators) + * @param iter Iterator handle + * @return Key string, BORROWED - valid until next iter_next or iter_destroy + */ +GOPHER_ORCH_API const char* gopher_orch_iter_key(gopher_orch_iterator_t iter) + GOPHER_ORCH_NOEXCEPT; + +/** + * Get current value + * @param iter Iterator handle + * @return Value handle, BORROWED - valid until next iter_next or iter_destroy + */ +GOPHER_ORCH_API gopher_orch_json_t +gopher_orch_iter_value(gopher_orch_iterator_t iter) GOPHER_ORCH_NOEXCEPT; + +/** + * Get current array index (for array iterators) + * @param iter Iterator handle + * @return Current index + */ +GOPHER_ORCH_API gopher_orch_size_t +gopher_orch_iter_index(gopher_orch_iterator_t iter) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Runnable API (Type-erased JSON-to-JSON) + * + * Core abstraction: all operations are exposed as JSON->JSON transformations. + * This provides the cleanest FFI surface. + * ============================================================================ + */ + +/** + * Increment reference count + * @param handle Runnable handle + */ +GOPHER_ORCH_API void gopher_orch_runnable_add_ref(gopher_orch_runnable_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Decrement reference count (destroys when count reaches 0) + * @param handle Runnable handle + */ +GOPHER_ORCH_API void gopher_orch_runnable_release(gopher_orch_runnable_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Get runnable name + * @param handle Runnable handle + * @return Name string, BORROWED + */ +GOPHER_ORCH_API const char* gopher_orch_runnable_name( + gopher_orch_runnable_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Invoke runnable asynchronously + * + * @param handle Runnable handle + * @param input Input JSON value + * @param config Configuration handle (NULL for defaults) + * @param dispatcher Dispatcher handle + * @param cancel_token Cancellation token (NULL if not needed) + * @param callback Completion callback + * @param user_context User context for callback + */ +GOPHER_ORCH_API void gopher_orch_runnable_invoke( + gopher_orch_runnable_t handle, + gopher_orch_json_t input, + gopher_orch_config_t config, + gopher_orch_dispatcher_t dispatcher, + gopher_orch_cancel_token_t cancel_token, + gopher_orch_completion_fn callback, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Invoke runnable synchronously (blocks until complete) + * + * @param handle Runnable handle + * @param input Input JSON value + * @param config Configuration handle (NULL for defaults) + * @param dispatcher Dispatcher handle + * @param cancel_token Cancellation token (NULL if not needed) + * @param out_result Output: result JSON handle (OWNED) + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_runnable_invoke_sync( + gopher_orch_runnable_t handle, + gopher_orch_json_t input, + gopher_orch_config_t config, + gopher_orch_dispatcher_t dispatcher, + gopher_orch_cancel_token_t cancel_token, + gopher_orch_json_t* out_result) GOPHER_ORCH_NOEXCEPT; + +/** + * Create lambda runnable from C function + * This is the primary way FFI users create custom runnables. + * + * @param fn Lambda function + * @param user_context User context passed to fn + * @param name Runnable name + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_lambda_create(gopher_orch_lambda_fn fn, + void* user_context, + const char* name) GOPHER_ORCH_NOEXCEPT; + +/** + * Create lambda with destructor for context cleanup + * + * @param fn Lambda function + * @param user_context User context passed to fn + * @param destructor Called when runnable is destroyed to cleanup context + * @param name Runnable name + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_lambda_create_with_destructor(gopher_orch_lambda_fn fn, + void* user_context, + gopher_orch_destructor_fn destructor, + const char* name) + GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Configuration API + * ============================================================================ + */ + +/** + * Create default configuration + * @return Config handle or NULL + */ +GOPHER_ORCH_API gopher_orch_config_t gopher_orch_config_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy configuration + * @param config Config handle + */ +GOPHER_ORCH_API void gopher_orch_config_destroy(gopher_orch_config_t config) + GOPHER_ORCH_NOEXCEPT; + +/** + * Set callback manager + * @param config Config handle + * @param manager Callback manager handle + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_config_set_callbacks( + gopher_orch_config_t config, + gopher_orch_callback_manager_t manager) GOPHER_ORCH_NOEXCEPT; + +/** + * Add tag to configuration + * @param config Config handle + * @param tag Tag string + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_config_add_tag( + gopher_orch_config_t config, const char* tag) GOPHER_ORCH_NOEXCEPT; + +/** + * Set metadata value + * @param config Config handle + * @param key Metadata key + * @param value Metadata value (takes ownership) + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_config_set_metadata(gopher_orch_config_t config, + const char* key, + gopher_orch_json_t value) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Composition API - Sequence + * + * Sequences execute runnables in order, passing output to next input. + * Builder pattern: create -> add steps -> build + * ============================================================================ + */ + +/** + * Create sequence builder + * @return Sequence builder handle or NULL + */ +GOPHER_ORCH_API gopher_orch_sequence_t gopher_orch_sequence_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy sequence builder (safe to call after build) + * @param handle Sequence builder handle + */ +GOPHER_ORCH_API void gopher_orch_sequence_destroy(gopher_orch_sequence_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Add step to sequence + * @param handle Sequence builder handle + * @param step Runnable to add (reference count incremented) + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_sequence_add(gopher_orch_sequence_t handle, + gopher_orch_runnable_t step) GOPHER_ORCH_NOEXCEPT; + +/** + * Build sequence into runnable + * @param handle Sequence builder handle + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_sequence_build(gopher_orch_sequence_t handle) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Composition API - Parallel + * + * Parallel executes multiple runnables concurrently, collecting results. + * ============================================================================ + */ + +/** + * Create parallel builder + * @return Parallel builder handle or NULL + */ +GOPHER_ORCH_API gopher_orch_parallel_t gopher_orch_parallel_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy parallel builder + * @param handle Parallel builder handle + */ +GOPHER_ORCH_API void gopher_orch_parallel_destroy(gopher_orch_parallel_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Add branch to parallel + * @param handle Parallel builder handle + * @param key Result key + * @param runnable Runnable for this branch + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_parallel_add(gopher_orch_parallel_t handle, + const char* key, + gopher_orch_runnable_t runnable) GOPHER_ORCH_NOEXCEPT; + +/** + * Build parallel into runnable + * @param handle Parallel builder handle + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_parallel_build(gopher_orch_parallel_t handle) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Composition API - Router + * + * Router selects between runnables based on conditions. + * ============================================================================ + */ + +/** + * Create router builder + * @return Router builder handle or NULL + */ +GOPHER_ORCH_API gopher_orch_router_t gopher_orch_router_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy router builder + * @param handle Router builder handle + */ +GOPHER_ORCH_API void gopher_orch_router_destroy(gopher_orch_router_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Add conditional route + * @param handle Router builder handle + * @param condition Condition function + * @param user_context Context for condition function + * @param runnable Runnable to use if condition matches + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_router_when(gopher_orch_router_t handle, + gopher_orch_condition_fn condition, + void* user_context, + gopher_orch_runnable_t runnable) GOPHER_ORCH_NOEXCEPT; + +/** + * Set default route + * @param handle Router builder handle + * @param runnable Runnable to use when no conditions match + * @return GOPHER_ORCH_OK on success + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_router_otherwise( + gopher_orch_router_t handle, + gopher_orch_runnable_t runnable) GOPHER_ORCH_NOEXCEPT; + +/** + * Build router into runnable + * @param handle Router builder handle + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_router_build(gopher_orch_router_t handle) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Resilience API + * + * Wrappers that add resilience patterns to runnables. + * ============================================================================ + */ + +/** + * Create retry wrapper + * @param inner Inner runnable (reference count incremented) + * @param policy Retry policy + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_retry_create( + gopher_orch_runnable_t inner, + const gopher_orch_retry_policy_t* policy) GOPHER_ORCH_NOEXCEPT; + +/** + * Create timeout wrapper + * @param inner Inner runnable + * @param timeout_ms Timeout in milliseconds + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_timeout_create( + gopher_orch_runnable_t inner, uint64_t timeout_ms) GOPHER_ORCH_NOEXCEPT; + +/** + * Create fallback wrapper + * @param primary Primary runnable + * @param fallbacks Array of fallback runnables + * @param fallback_count Number of fallbacks + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_fallback_create( + gopher_orch_runnable_t primary, + gopher_orch_runnable_t* fallbacks, + gopher_orch_size_t fallback_count) GOPHER_ORCH_NOEXCEPT; + +/** + * Create circuit breaker wrapper + * @param inner Inner runnable + * @param policy Circuit breaker policy + * @return Runnable handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_circuit_breaker_create( + gopher_orch_runnable_t inner, + const gopher_orch_circuit_breaker_policy_t* policy) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Server API + * + * MCP server connections for tool invocation. + * ============================================================================ + */ + +/** + * Increment server reference count + */ +GOPHER_ORCH_API void gopher_orch_server_add_ref(gopher_orch_server_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Decrement server reference count + */ +GOPHER_ORCH_API void gopher_orch_server_release(gopher_orch_server_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Get server ID + */ +GOPHER_ORCH_API const char* gopher_orch_server_id(gopher_orch_server_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Get server name + */ +GOPHER_ORCH_API const char* gopher_orch_server_name(gopher_orch_server_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Check if server is connected + */ +GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_server_is_connected( + gopher_orch_server_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Get tool count + */ +GOPHER_ORCH_API gopher_orch_size_t +gopher_orch_server_tool_count(gopher_orch_server_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Get tool name by index + */ +GOPHER_ORCH_API const char* gopher_orch_server_tool_name( + gopher_orch_server_t handle, gopher_orch_size_t index) GOPHER_ORCH_NOEXCEPT; + +/** + * Get tool as runnable + * @param handle Server handle + * @param tool_name Tool name + * @return Runnable handle or NULL if not found + */ +GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_server_tool( + gopher_orch_server_t handle, const char* tool_name) GOPHER_ORCH_NOEXCEPT; + +/** + * Call tool directly (async) + */ +GOPHER_ORCH_API void gopher_orch_server_call_tool( + gopher_orch_server_t handle, + const char* tool_name, + gopher_orch_json_t arguments, + gopher_orch_dispatcher_t dispatcher, + gopher_orch_cancel_token_t cancel_token, + gopher_orch_completion_fn callback, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Mock Server API (for testing) + * ============================================================================ + */ + +/** + * Create mock server + */ +GOPHER_ORCH_API gopher_orch_server_t +gopher_orch_mock_server_create(const char* name) GOPHER_ORCH_NOEXCEPT; + +/** + * Add tool to mock server + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_mock_server_add_tool(gopher_orch_server_t handle, + const char* tool_name, + const char* description) GOPHER_ORCH_NOEXCEPT; + +/** + * Set tool response + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_mock_server_set_response( + gopher_orch_server_t handle, + const char* tool_name, + gopher_orch_json_t response) GOPHER_ORCH_NOEXCEPT; + +/** + * Set tool error + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_mock_server_set_error( + gopher_orch_server_t handle, + const char* tool_name, + gopher_orch_error_t error_code, + const char* error_message) GOPHER_ORCH_NOEXCEPT; + +/** + * Get call count + */ +GOPHER_ORCH_API gopher_orch_size_t gopher_orch_mock_server_call_count( + gopher_orch_server_t handle, const char* tool_name) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * MCP Server API (real connections) + * ============================================================================ + */ + +/** + * Server creation callback + */ +typedef void (*gopher_orch_server_fn)(void* user_context, + gopher_orch_error_t error, + gopher_orch_server_t server); + +/** + * Create MCP server connection (async) + */ +GOPHER_ORCH_API void gopher_orch_mcp_server_create( + const gopher_orch_mcp_config_t* config, + gopher_orch_dispatcher_t dispatcher, + gopher_orch_server_fn callback, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Close MCP server connection + */ +GOPHER_ORCH_API void gopher_orch_mcp_server_close(gopher_orch_server_t handle, + gopher_orch_work_fn on_closed, + void* user_context) + GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Callback Manager API (Observability) + * ============================================================================ + */ + +/** + * Create callback manager + */ +GOPHER_ORCH_API gopher_orch_callback_manager_t +gopher_orch_callback_manager_create(void) GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy callback manager + */ +GOPHER_ORCH_API void gopher_orch_callback_manager_destroy( + gopher_orch_callback_manager_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Add callback handler + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_callback_manager_add_handler( + gopher_orch_callback_manager_t handle, + const gopher_orch_callback_handler_config_t* config) GOPHER_ORCH_NOEXCEPT; + +/** + * Get handler count + */ +GOPHER_ORCH_API gopher_orch_size_t gopher_orch_callback_manager_handler_count( + gopher_orch_callback_manager_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Clear all handlers + */ +GOPHER_ORCH_API void gopher_orch_callback_manager_clear( + gopher_orch_callback_manager_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Create child manager (inherits handlers, sets parent_run_id) + */ +GOPHER_ORCH_API gopher_orch_callback_manager_t +gopher_orch_callback_manager_child(gopher_orch_callback_manager_t handle) + GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Approval Handler API (Human-in-the-Loop) + * ============================================================================ + */ + +/** + * Create auto-approve handler (for testing) + */ +GOPHER_ORCH_API gopher_orch_approval_handler_t +gopher_orch_auto_approval_create(const char* reason) GOPHER_ORCH_NOEXCEPT; + +/** + * Create auto-deny handler (for testing) + */ +GOPHER_ORCH_API gopher_orch_approval_handler_t +gopher_orch_auto_deny_create(const char* reason) GOPHER_ORCH_NOEXCEPT; + +/** + * Create callback-based approval handler + */ +GOPHER_ORCH_API gopher_orch_approval_handler_t +gopher_orch_callback_approval_create(gopher_orch_approval_fn fn, + void* user_context, + gopher_orch_destructor_fn destructor) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy approval handler + */ +GOPHER_ORCH_API void gopher_orch_approval_handler_destroy( + gopher_orch_approval_handler_t handle) GOPHER_ORCH_NOEXCEPT; + +/** + * Create human approval wrapper + */ +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_human_approval_create(gopher_orch_runnable_t inner, + gopher_orch_approval_handler_t handler, + const char* prompt) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * State Machine API (FSM with int32_t states/events) + * ============================================================================ + */ + +/** + * Create state machine + */ +GOPHER_ORCH_API gopher_orch_fsm_t gopher_orch_fsm_create(int32_t initial_state) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy state machine + */ +GOPHER_ORCH_API void gopher_orch_fsm_destroy(gopher_orch_fsm_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Add transition + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_fsm_add_transition(gopher_orch_fsm_t handle, + int32_t from_state, + int32_t event, + int32_t to_state) GOPHER_ORCH_NOEXCEPT; + +/** + * Set guard for transition + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_fsm_set_guard(gopher_orch_fsm_t handle, + int32_t from_state, + int32_t event, + gopher_orch_guard_fn guard, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Set action for transition + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_fsm_set_action(gopher_orch_fsm_t handle, + int32_t from_state, + int32_t event, + gopher_orch_action_fn action, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Set state entry action + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_fsm_on_enter(gopher_orch_fsm_t handle, + int32_t state, + gopher_orch_action_fn action, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Set state exit action + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_fsm_on_exit(gopher_orch_fsm_t handle, + int32_t state, + gopher_orch_action_fn action, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Set transition observer + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_fsm_set_observer(gopher_orch_fsm_t handle, + gopher_orch_transition_fn observer, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Get current state + */ +GOPHER_ORCH_API int32_t gopher_orch_fsm_current_state(gopher_orch_fsm_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Check if event can trigger transition + */ +GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_fsm_can_trigger( + gopher_orch_fsm_t handle, int32_t event) GOPHER_ORCH_NOEXCEPT; + +/** + * Trigger event (sync) + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_fsm_trigger(gopher_orch_fsm_t handle, + int32_t event, + int32_t* out_new_state) GOPHER_ORCH_NOEXCEPT; + +/** + * Trigger event (async) + */ +typedef void (*gopher_orch_fsm_trigger_fn)(void* user_context, + gopher_orch_error_t error, + int32_t new_state); + +GOPHER_ORCH_API void gopher_orch_fsm_trigger_async( + gopher_orch_fsm_t handle, + int32_t event, + gopher_orch_dispatcher_t dispatcher, + gopher_orch_fsm_trigger_fn callback, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * State Graph API + * + * Graph-based workflows with conditional edges (JSON state). + * ============================================================================ + */ + +/** + * Create state graph builder + */ +GOPHER_ORCH_API gopher_orch_graph_t gopher_orch_graph_create(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Destroy state graph builder + */ +GOPHER_ORCH_API void gopher_orch_graph_destroy(gopher_orch_graph_t handle) + GOPHER_ORCH_NOEXCEPT; + +/** + * Add node to graph + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_graph_add_node( + gopher_orch_graph_t handle, + const char* name, + gopher_orch_runnable_t runnable) GOPHER_ORCH_NOEXCEPT; + +/** + * Add edge from one node to another + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_graph_add_edge(gopher_orch_graph_t handle, + const char* from, + const char* to) GOPHER_ORCH_NOEXCEPT; + +/** + * Add conditional edge (router-style) + */ +GOPHER_ORCH_API gopher_orch_error_t +gopher_orch_graph_add_conditional_edge(gopher_orch_graph_t handle, + const char* from, + gopher_orch_edge_condition_fn condition, + void* user_context) GOPHER_ORCH_NOEXCEPT; + +/** + * Set entry point + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_graph_set_entry( + gopher_orch_graph_t handle, const char* node_name) GOPHER_ORCH_NOEXCEPT; + +/** + * Add state channel with reducer + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_graph_add_channel( + gopher_orch_graph_t handle, + const char* key, + gopher_orch_channel_type_t type) GOPHER_ORCH_NOEXCEPT; + +/** + * Compile graph into runnable + */ +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_graph_compile(gopher_orch_graph_t handle) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Resource Statistics and Debugging + * ============================================================================ + */ + +/** + * Get resource statistics + */ +GOPHER_ORCH_API gopher_orch_error_t gopher_orch_get_resource_stats( + gopher_orch_size_t* active_count, + gopher_orch_size_t* total_created, + gopher_orch_size_t* total_destroyed) GOPHER_ORCH_NOEXCEPT; + +/** + * Check for resource leaks + * @return Number of leaked resources + */ +GOPHER_ORCH_API gopher_orch_size_t gopher_orch_check_leaks(void) + GOPHER_ORCH_NOEXCEPT; + +/** + * Print leak report to stderr + */ +GOPHER_ORCH_API void gopher_orch_print_leak_report(void) GOPHER_ORCH_NOEXCEPT; + +/* ============================================================================ + * Agent API + * + * High-level AI agent functionality using ReActAgent pattern. + * ============================================================================ + */ + +/** + * Create agent from JSON server configuration + * @param provider_name LLM provider name (e.g., "AnthropicProvider") + * @param model_name Model name (e.g., "claude-3-haiku-20240307") + * @param server_json_config JSON string containing MCP server configuration + * @return Agent handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_agent_t gopher_orch_agent_create_by_json( + const char* provider_name, + const char* model_name, + const char* server_json_config) GOPHER_ORCH_NOEXCEPT; + +/** + * Create agent using API key (fetches server config automatically) + * @param provider_name LLM provider name (e.g., "AnthropicProvider") + * @param model_name Model name (e.g., "claude-3-haiku-20240307") + * @param api_key API key for fetching server configuration + * @return Agent handle or NULL on error + */ +GOPHER_ORCH_API gopher_orch_agent_t gopher_orch_agent_create_by_api_key( + const char* provider_name, + const char* model_name, + const char* api_key) GOPHER_ORCH_NOEXCEPT; + +/** + * Run agent query synchronously + * @param agent Agent handle + * @param query User query string + * @param timeout_ms Timeout in milliseconds (0 for no timeout) + * @return Response string (OWNED - caller must gopher_orch_free) + */ +GOPHER_ORCH_API char* gopher_orch_agent_run( + gopher_orch_agent_t agent, + const char* query, + uint64_t timeout_ms) GOPHER_ORCH_NOEXCEPT; + +/** + * Increment agent reference count + * @param agent Agent handle + */ +GOPHER_ORCH_API void gopher_orch_agent_add_ref(gopher_orch_agent_t agent) + GOPHER_ORCH_NOEXCEPT; + +/** + * Decrement agent reference count (destroys when count reaches 0) + * @param agent Agent handle + */ +GOPHER_ORCH_API void gopher_orch_agent_release(gopher_orch_agent_t agent) + GOPHER_ORCH_NOEXCEPT; + +/** + * Fetch MCP server configurations from API + * @param api_key API key for authentication + * @return JSON configuration string (OWNED - caller must gopher_orch_free) + */ +GOPHER_ORCH_API char* gopher_orch_api_fetch_servers(const char* api_key) + GOPHER_ORCH_NOEXCEPT; + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +/* ============================================================================ + * RAII Helper Macros (for C++ users of the C API) + * ============================================================================ + */ + +#ifdef __cplusplus + +#include +#include + +/* Automatic cleanup guard for any handle */ +#define GOPHER_ORCH_AUTO_GUARD(handle, type) \ + std::unique_ptr> _guard_##__LINE__( \ + handle, [](void* h) { \ + if (h) { \ + auto guard = gopher_orch_guard_create(h, type); \ + gopher_orch_guard_destroy(&guard); \ + } \ + }) + +/* Scoped transaction with automatic rollback */ +#define GOPHER_ORCH_SCOPED_TRANSACTION(name) \ + struct _TxnGuard_##__LINE__ { \ + gopher_orch_transaction_t txn; \ + bool committed = false; \ + _TxnGuard_##__LINE__() : txn(gopher_orch_transaction_create()) {} \ + ~_TxnGuard_##__LINE__() { \ + if (txn && !committed) { \ + gopher_orch_transaction_rollback(&txn); \ + } \ + } \ + void commit() { \ + if (txn) { \ + gopher_orch_transaction_commit(&txn); \ + committed = true; \ + } \ + } \ + } name + +#endif /* __cplusplus */ + +/* ============================================================================ + * RAII Patterns for C Users + * ============================================================================ + */ + +/* Guard creation macro */ +#define GOPHER_ORCH_GUARD_CREATE(handle, type) \ + gopher_orch_guard_create(handle, type) + +/* Safe resource release macro */ +#define GOPHER_ORCH_SAFE_RELEASE(guard_ptr) \ + do { \ + if (guard_ptr && *(guard_ptr)) { \ + gopher_orch_guard_destroy(guard_ptr); \ + } \ + } while (0) + +/* Safe transaction cleanup macro */ +#define GOPHER_ORCH_SAFE_TXN_CLEANUP(txn_ptr) \ + do { \ + if (txn_ptr && *(txn_ptr)) { \ + gopher_orch_transaction_rollback(txn_ptr); \ + } \ + } while (0) + +#endif /* GOPHER_ORCH_FFI_H */ diff --git a/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_bridge.h b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_bridge.h new file mode 100644 index 00000000..d1dac078 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_bridge.h @@ -0,0 +1,865 @@ +/** + * @file orch_ffi_bridge.h + * @brief Internal C++ to C bridge for gopher-orch FFI layer + * + * This header provides the internal bridge between C++ and C APIs with + * comprehensive RAII support, FFI-safe type conversions, and automatic + * resource management. It ensures thread-safe operations and prevents + * resource leaks through systematic RAII enforcement. + * + * Architecture: + * - RAII wrappers for all C++ resources + * - Thread-safe handle management with reference counting + * - Automatic cleanup through scope guards and transactions + * - FFI-safe type conversions with validation + * - Comprehensive error handling with recovery + * + * Key Design Decisions: + * - JSON-to-JSON type erasure at FFI boundary + * - All Runnable templates exposed as JsonRunnable + * - Thread-local error messages for C API + * - Opaque handles with reference counting + * + * This file is NOT part of the public API and should only be included + * by implementation files. + */ + +#ifndef GOPHER_ORCH_FFI_BRIDGE_H +#define GOPHER_ORCH_FFI_BRIDGE_H + +#include "orch_ffi.h" + +/* C++ standard library headers */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* gopher-orch C++ headers */ +#include "gopher/orch/agent/agent.h" +#include "gopher/orch/callback/callback_handler.h" +#include "gopher/orch/callback/callback_manager.h" +#include "gopher/orch/core/config.h" +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/core/types.h" +#include "gopher/orch/human/approval.h" + +/* mcp headers for dispatcher */ +#include "mcp/event/libevent_dispatcher.h" + +namespace gopher { +namespace orch { +namespace ffi { + +/* ============================================================================ + * Handle Base Class + * + * All FFI handle implementations derive from this base class. + * Provides reference counting and global registry for leak detection. + * ============================================================================ + */ + +class HandleBase { + public: + explicit HandleBase(gopher_orch_type_id_t type) + : ref_count_(1), type_id_(type) { + RegisterHandle(this); + } + + virtual ~HandleBase() { UnregisterHandle(this); } + + /* Reference counting */ + void AddRef() { ref_count_.fetch_add(1, std::memory_order_relaxed); } + + void Release() { + if (ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + delete this; + } + } + + int32_t GetRefCount() const { + return ref_count_.load(std::memory_order_relaxed); + } + + gopher_orch_type_id_t GetType() const { return type_id_; } + + /* Virtual methods for resource management */ + virtual void Cleanup() {} + virtual bool IsValid() const { return true; } + + private: + std::atomic ref_count_; + gopher_orch_type_id_t type_id_; + + /* Global handle registry */ + static void RegisterHandle(HandleBase* handle); + static void UnregisterHandle(HandleBase* handle); +}; + +/* ============================================================================ + * Handle Registry for Leak Detection + * ============================================================================ + */ + +class HandleRegistry { + public: + static HandleRegistry& Instance() { + static HandleRegistry instance; + return instance; + } + + void Register(HandleBase* handle) { + if (!handle) + return; + std::lock_guard lock(mutex_); + handles_.insert(handle); + stats_.total_created++; + } + + void Unregister(HandleBase* handle) { + if (!handle) + return; + std::lock_guard lock(mutex_); + handles_.erase(handle); + stats_.total_destroyed++; + } + + bool IsValid(void* handle) const { + if (!handle) + return false; + std::lock_guard lock(mutex_); + return handles_.find(static_cast(handle)) != handles_.end(); + } + + struct Stats { + size_t total_created{0}; + size_t total_destroyed{0}; + }; + + Stats GetStats() const { + std::lock_guard lock(mutex_); + return stats_; + } + + size_t GetActiveCount() const { + std::lock_guard lock(mutex_); + return handles_.size(); + } + + void PrintLeakReport() const { + std::lock_guard lock(mutex_); + if (!handles_.empty()) { + fprintf(stderr, "gopher-orch FFI: %zu handles leaked:\n", + handles_.size()); + for (auto* handle : handles_) { + fprintf(stderr, " - Handle type %d at %p (refcount=%d)\n", + handle->GetType(), static_cast(handle), + handle->GetRefCount()); + } + } + } + + private: + mutable std::mutex mutex_; + std::unordered_set handles_; + Stats stats_; +}; + +/* Inline implementations */ +inline void HandleBase::RegisterHandle(HandleBase* handle) { + HandleRegistry::Instance().Register(handle); +} + +inline void HandleBase::UnregisterHandle(HandleBase* handle) { + HandleRegistry::Instance().Unregister(handle); +} + +/* ============================================================================ + * Error Manager - Thread-local error handling + * ============================================================================ + */ + +class ErrorManager { + public: + static void SetError(gopher_orch_error_t code, + const std::string& message, + const std::string& details = "", + const char* file = nullptr, + int line = 0) { + auto& info = GetThreadLocalError(); + info.code = code; + + /* Store message in thread-local storage */ + auto& msg = GetThreadLocalMessage(); + auto& det = GetThreadLocalDetails(); + msg = message; + det = details; + + info.message = msg.c_str(); + info.details = det.empty() ? nullptr : det.c_str(); + info.file = file; + info.line = line; + } + + static const gopher_orch_error_info_t* GetLastError() { + auto& info = GetThreadLocalError(); + return (info.code != GOPHER_ORCH_OK) ? &info : nullptr; + } + + static void ClearError() { + auto& info = GetThreadLocalError(); + info.code = GOPHER_ORCH_OK; + info.message = nullptr; + info.details = nullptr; + info.file = nullptr; + info.line = 0; + } + + static const char* GetErrorName(gopher_orch_error_t code) { + switch (code) { + case GOPHER_ORCH_OK: + return "GOPHER_ORCH_OK"; + case GOPHER_ORCH_ERROR_INVALID_HANDLE: + return "GOPHER_ORCH_ERROR_INVALID_HANDLE"; + case GOPHER_ORCH_ERROR_INVALID_ARGUMENT: + return "GOPHER_ORCH_ERROR_INVALID_ARGUMENT"; + case GOPHER_ORCH_ERROR_NULL_POINTER: + return "GOPHER_ORCH_ERROR_NULL_POINTER"; + case GOPHER_ORCH_ERROR_NOT_FOUND: + return "GOPHER_ORCH_ERROR_NOT_FOUND"; + case GOPHER_ORCH_ERROR_ALREADY_EXISTS: + return "GOPHER_ORCH_ERROR_ALREADY_EXISTS"; + case GOPHER_ORCH_ERROR_RESOURCE_LIMIT: + return "GOPHER_ORCH_ERROR_RESOURCE_LIMIT"; + case GOPHER_ORCH_ERROR_NO_MEMORY: + return "GOPHER_ORCH_ERROR_NO_MEMORY"; + case GOPHER_ORCH_ERROR_CONNECTION_FAILED: + return "GOPHER_ORCH_ERROR_CONNECTION_FAILED"; + case GOPHER_ORCH_ERROR_NOT_CONNECTED: + return "GOPHER_ORCH_ERROR_NOT_CONNECTED"; + case GOPHER_ORCH_ERROR_TIMEOUT: + return "GOPHER_ORCH_ERROR_TIMEOUT"; + case GOPHER_ORCH_ERROR_INVALID_TRANSITION: + return "GOPHER_ORCH_ERROR_INVALID_TRANSITION"; + case GOPHER_ORCH_ERROR_GUARD_REJECTED: + return "GOPHER_ORCH_ERROR_GUARD_REJECTED"; + case GOPHER_ORCH_ERROR_INVALID_STATE: + return "GOPHER_ORCH_ERROR_INVALID_STATE"; + case GOPHER_ORCH_ERROR_CANCELLED: + return "GOPHER_ORCH_ERROR_CANCELLED"; + case GOPHER_ORCH_ERROR_APPROVAL_DENIED: + return "GOPHER_ORCH_ERROR_APPROVAL_DENIED"; + case GOPHER_ORCH_ERROR_CIRCUIT_OPEN: + return "GOPHER_ORCH_ERROR_CIRCUIT_OPEN"; + case GOPHER_ORCH_ERROR_FALLBACK_EXHAUSTED: + return "GOPHER_ORCH_ERROR_FALLBACK_EXHAUSTED"; + case GOPHER_ORCH_ERROR_PARSE_ERROR: + return "GOPHER_ORCH_ERROR_PARSE_ERROR"; + case GOPHER_ORCH_ERROR_INVALID_JSON: + return "GOPHER_ORCH_ERROR_INVALID_JSON"; + case GOPHER_ORCH_ERROR_INTERNAL: + return "GOPHER_ORCH_ERROR_INTERNAL"; + case GOPHER_ORCH_ERROR_NOT_IMPLEMENTED: + return "GOPHER_ORCH_ERROR_NOT_IMPLEMENTED"; + default: + return "GOPHER_ORCH_ERROR_UNKNOWN"; + } + } + + private: + static gopher_orch_error_info_t& GetThreadLocalError() { + thread_local gopher_orch_error_info_t info = {}; + return info; + } + + static std::string& GetThreadLocalMessage() { + thread_local std::string message; + return message; + } + + static std::string& GetThreadLocalDetails() { + thread_local std::string details; + return details; + } +}; + +/* Macro for setting error with file/line */ +#define SET_ERROR(code, msg) \ + ErrorManager::SetError(code, msg, "", __FILE__, __LINE__) + +#define SET_ERROR_DETAIL(code, msg, detail) \ + ErrorManager::SetError(code, msg, detail, __FILE__, __LINE__) + +/* ============================================================================ + * Handle Implementations + * ============================================================================ + */ + +/** + * JSON value handle implementation + */ +struct JsonImpl : public HandleBase { + explicit JsonImpl(core::JsonValue value) + : HandleBase(GOPHER_ORCH_TYPE_JSON), value(std::move(value)) {} + + core::JsonValue value; +}; + +/** + * Dispatcher handle implementation + * Uses LibeventDispatcher as the concrete implementation + */ +struct DispatcherImpl : public HandleBase { + DispatcherImpl() + : HandleBase(GOPHER_ORCH_TYPE_DISPATCHER), + dispatcher(std::make_unique("ffi")) {} + + ~DispatcherImpl() override { Cleanup(); } + + void Cleanup() override { + if (dispatcher) { + dispatcher->exit(); + } + } + + std::unique_ptr dispatcher; + std::thread::id dispatcher_thread_id; +}; + +/** + * Configuration handle implementation + */ +struct ConfigImpl : public HandleBase { + ConfigImpl() : HandleBase(GOPHER_ORCH_TYPE_CONFIG) {} + + core::RunnableConfig config; +}; + +/** + * Runnable handle implementation - type-erased to JSON->JSON + */ +struct RunnableImpl : public HandleBase { + using JsonRunnable = core::Runnable; + + explicit RunnableImpl(std::shared_ptr runnable) + : HandleBase(GOPHER_ORCH_TYPE_RUNNABLE), runnable(std::move(runnable)) {} + + std::shared_ptr runnable; +}; + +/** + * Agent handle implementation - wraps ReActAgent functionality + */ +struct AgentImpl : public HandleBase { + explicit AgentImpl(std::shared_ptr agent) + : HandleBase(GOPHER_ORCH_TYPE_AGENT), agent(std::move(agent)) {} + + std::shared_ptr agent; +}; + +/** + * Callback manager handle implementation + */ +struct CallbackManagerImpl : public HandleBase { + CallbackManagerImpl() + : HandleBase(GOPHER_ORCH_TYPE_CALLBACK_MANAGER), + manager(std::make_shared()) {} + + std::shared_ptr manager; +}; + +/** + * Approval handler handle implementation + */ +struct ApprovalHandlerImpl : public HandleBase { + explicit ApprovalHandlerImpl(std::shared_ptr handler) + : HandleBase(GOPHER_ORCH_TYPE_APPROVAL_HANDLER), + handler(std::move(handler)) {} + + std::shared_ptr handler; +}; + +/** + * Cancellation token implementation + */ +struct CancelTokenImpl : public HandleBase { + CancelTokenImpl() : HandleBase(GOPHER_ORCH_TYPE_CANCEL_TOKEN) {} + + std::atomic cancelled{false}; +}; + +/** + * Iterator implementation + * Stores a copy of the keys for object iteration since ObjectIterator + * doesn't support proper copy semantics + */ +struct IteratorImpl : public HandleBase { + IteratorImpl(gopher_orch_json_t json) + : HandleBase(GOPHER_ORCH_TYPE_ITERATOR), json_(json), index_(0) { + if (json) { + auto* impl = reinterpret_cast(json); + if (impl->value.isObject()) { + is_object_ = true; + /* Store all keys for iteration */ + object_keys_ = impl->value.keys(); + } else if (impl->value.isArray()) { + is_object_ = false; + array_size_ = impl->value.size(); + } + } + } + + gopher_orch_json_t json_; + size_t index_; + bool is_object_ = false; + std::vector object_keys_; + size_t array_size_ = 0; + std::string current_key_; + core::JsonValue current_value_; +}; + +/** + * Sequence builder implementation + */ +struct SequenceImpl : public HandleBase { + SequenceImpl() : HandleBase(GOPHER_ORCH_TYPE_SEQUENCE) {} + + std::vector> steps; +}; + +/** + * Parallel builder implementation + */ +struct ParallelImpl : public HandleBase { + ParallelImpl() : HandleBase(GOPHER_ORCH_TYPE_PARALLEL) {} + + std::vector< + std::pair>> + branches; +}; + +/** + * Router builder implementation + */ +struct RouterImpl : public HandleBase { + RouterImpl() : HandleBase(GOPHER_ORCH_TYPE_ROUTER) {} + + struct Route { + gopher_orch_condition_fn condition; + void* user_context; + std::shared_ptr runnable; + }; + + std::vector routes; + std::shared_ptr default_route; +}; + +/** + * RAII guard implementation + */ +struct GuardImpl : public HandleBase { + GuardImpl(void* handle, + gopher_orch_type_id_t type, + gopher_orch_cleanup_fn cleanup) + : HandleBase(GOPHER_ORCH_TYPE_GUARD), + handle_(handle), + type_(type), + cleanup_(cleanup), + released_(false) {} + + ~GuardImpl() override { + if (!released_ && handle_ && cleanup_) { + cleanup_(handle_); + } + } + + void* Release() { + void* h = handle_; + handle_ = nullptr; + released_ = true; + return h; + } + + void* handle_; + gopher_orch_type_id_t type_; + gopher_orch_cleanup_fn cleanup_; + bool released_; +}; + +/** + * Transaction implementation + */ +struct TransactionImpl : public HandleBase { + struct Resource { + void* handle; + gopher_orch_type_id_t type; + gopher_orch_cleanup_fn cleanup; + }; + + explicit TransactionImpl(const gopher_orch_transaction_opts_t* opts) + : HandleBase(GOPHER_ORCH_TYPE_TRANSACTION), committed_(false) { + if (opts) { + auto_rollback_ = opts->auto_rollback; + strict_ordering_ = opts->strict_ordering; + max_resources_ = opts->max_resources; + } + } + + ~TransactionImpl() override { + if (!committed_ && auto_rollback_) { + Rollback(); + } + } + + gopher_orch_error_t Add(void* handle, gopher_orch_type_id_t type) { + if (!handle) + return GOPHER_ORCH_ERROR_NULL_POINTER; + if (committed_) + return GOPHER_ORCH_ERROR_INVALID_STATE; + if (max_resources_ > 0 && resources_.size() >= max_resources_) + return GOPHER_ORCH_ERROR_RESOURCE_LIMIT; + + resources_.push_back({handle, type, nullptr}); + return GOPHER_ORCH_OK; + } + + gopher_orch_error_t Commit() { + if (committed_) + return GOPHER_ORCH_ERROR_INVALID_STATE; + committed_ = true; + resources_.clear(); + return GOPHER_ORCH_OK; + } + + void Rollback() { + if (committed_) + return; + + /* Cleanup in reverse order (LIFO) */ + while (!resources_.empty()) { + auto& res = resources_.back(); + CleanupResource(res); + resources_.pop_back(); + } + committed_ = true; + } + + size_t Size() const { return resources_.size(); } + + private: + void CleanupResource(const Resource& res) { + if (!res.handle) + return; + + if (res.cleanup) { + res.cleanup(res.handle); + } else { + /* Default cleanup based on type */ + auto* base = static_cast(res.handle); + base->Release(); + } + } + + std::vector resources_; + bool committed_; + bool auto_rollback_ = true; + bool strict_ordering_ = true; + size_t max_resources_ = 0; +}; + +/* ============================================================================ + * Lambda Runnable Implementation + * + * Wraps a C callback function as a JsonRunnable. + * ============================================================================ + */ + +class LambdaRunnable : public core::Runnable { + public: + LambdaRunnable(gopher_orch_lambda_fn fn, + void* user_context, + gopher_orch_destructor_fn destructor, + std::string name) + : fn_(fn), + user_context_(user_context), + destructor_(destructor), + name_(std::move(name)) {} + + ~LambdaRunnable() override { + if (destructor_ && user_context_) { + destructor_(user_context_); + } + } + + std::string name() const override { return name_; } + + void invoke(const core::JsonValue& input, + const core::RunnableConfig& config, + core::Dispatcher& dispatcher, + core::ResultCallback callback) override { + (void)config; + + /* Create input handle for callback */ + auto* input_impl = new JsonImpl(input); + + /* Post to dispatcher to call the callback in the right context */ + dispatcher.post([this, input_impl, callback]() { + gopher_orch_error_t error = GOPHER_ORCH_OK; + auto result = + fn_(user_context_, reinterpret_cast(input_impl), + &error); + + /* Cleanup input handle */ + input_impl->Release(); + + if (error != GOPHER_ORCH_OK || !result) { + callback(core::Result( + core::Error(error, ErrorManager::GetErrorName(error)))); + } else { + auto* result_impl = reinterpret_cast(result); + core::JsonValue output = result_impl->value; + result_impl->Release(); + callback(core::makeSuccess(std::move(output))); + } + }); + } + + private: + gopher_orch_lambda_fn fn_; + void* user_context_; + gopher_orch_destructor_fn destructor_; + std::string name_; +}; + +/* ============================================================================ + * FFI Callback Handler Implementation + * + * Wraps C callback functions as a CallbackHandler. + * ============================================================================ + */ + +class FFICallbackHandler : public callback::CallbackHandler { + public: + explicit FFICallbackHandler( + const gopher_orch_callback_handler_config_t& config) + : config_(config) {} + + ~FFICallbackHandler() override { + if (config_.destructor && config_.user_context) { + config_.destructor(config_.user_context); + } + } + + void onChainStart(const callback::RunInfo& info, + const core::JsonValue& input) override { + if (config_.on_chain_start) { + auto* input_impl = new JsonImpl(input); + config_.on_chain_start(config_.user_context, info.run_id.c_str(), + info.name.c_str(), + reinterpret_cast(input_impl)); + input_impl->Release(); + } + } + + void onChainEnd(const callback::RunInfo& info, + const core::JsonValue& output) override { + if (config_.on_chain_end) { + auto* output_impl = new JsonImpl(output); + config_.on_chain_end(config_.user_context, info.run_id.c_str(), + info.name.c_str(), + reinterpret_cast(output_impl)); + output_impl->Release(); + } + } + + void onChainError(const callback::RunInfo& info, + const core::Error& error) override { + if (config_.on_chain_error) { + config_.on_chain_error( + config_.user_context, info.run_id.c_str(), info.name.c_str(), + static_cast(error.code), error.message.c_str()); + } + } + + void onToolStart(const callback::RunInfo& info, + const std::string& tool_name, + const core::JsonValue& input) override { + if (config_.on_tool_start) { + auto* input_impl = new JsonImpl(input); + config_.on_tool_start(config_.user_context, info.run_id.c_str(), + tool_name.c_str(), + reinterpret_cast(input_impl)); + input_impl->Release(); + } + } + + void onToolEnd(const callback::RunInfo& info, + const std::string& tool_name, + const core::JsonValue& output) override { + if (config_.on_tool_end) { + auto* output_impl = new JsonImpl(output); + config_.on_tool_end(config_.user_context, info.run_id.c_str(), + tool_name.c_str(), + reinterpret_cast(output_impl)); + output_impl->Release(); + } + } + + void onToolError(const callback::RunInfo& info, + const std::string& tool_name, + const core::Error& error) override { + if (config_.on_tool_error) { + config_.on_tool_error( + config_.user_context, info.run_id.c_str(), tool_name.c_str(), + static_cast(error.code), error.message.c_str()); + } + } + + void onRetry(const callback::RunInfo& info, + const core::Error& error, + uint32_t attempt, + uint32_t max_attempts) override { + if (config_.on_retry) { + config_.on_retry( + config_.user_context, info.run_id.c_str(), info.name.c_str(), + static_cast(error.code), attempt, max_attempts); + } + } + + void onCustomEvent(const std::string& event_name, + const core::JsonValue& data) override { + if (config_.on_custom_event) { + auto* data_impl = new JsonImpl(data); + config_.on_custom_event(config_.user_context, event_name.c_str(), + reinterpret_cast(data_impl)); + data_impl->Release(); + } + } + + private: + gopher_orch_callback_handler_config_t config_; +}; + +/* ============================================================================ + * FFI Approval Handler Implementation + * + * Wraps C callback function as an ApprovalHandler. + * ============================================================================ + */ + +class FFIApprovalHandler : public human::ApprovalHandler { + public: + FFIApprovalHandler(gopher_orch_approval_fn fn, + void* user_context, + gopher_orch_destructor_fn destructor) + : fn_(fn), user_context_(user_context), destructor_(destructor) {} + + ~FFIApprovalHandler() override { + if (destructor_ && user_context_) { + destructor_(user_context_); + } + } + + void requestApproval( + const human::ApprovalRequest& request, + std::function callback) override { + /* Create preview handle */ + auto* preview_impl = new JsonImpl(request.preview); + + gopher_orch_bool_t approved = GOPHER_ORCH_FALSE; + char* reason = nullptr; + gopher_orch_json_t modifications = nullptr; + + fn_(user_context_, request.action_name.c_str(), + reinterpret_cast(preview_impl), + request.prompt.c_str(), &approved, &reason, &modifications); + + preview_impl->Release(); + + /* Build response */ + human::ApprovalResponse response; + response.approved = (approved != GOPHER_ORCH_FALSE); + response.reason = reason ? reason : ""; + + if (reason) { + gopher_orch_free(reason); + } + + if (modifications) { + auto* mod_impl = reinterpret_cast(modifications); + response.modifications = mod_impl->value; + mod_impl->Release(); + } + + callback(std::move(response)); + } + + private: + gopher_orch_approval_fn fn_; + void* user_context_; + gopher_orch_destructor_fn destructor_; +}; + +/* ============================================================================ + * Utility Macros for Handle Validation + * ============================================================================ + */ + +#define CHECK_HANDLE(handle, type_enum, return_val) \ + do { \ + if (!handle) { \ + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_HANDLE, "Handle is null"); \ + return return_val; \ + } \ + auto* base = reinterpret_cast(handle); \ + if (base->GetType() != type_enum) { \ + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_HANDLE, "Handle type mismatch"); \ + return return_val; \ + } \ + } while (0) + +#define CHECK_HANDLE_VOID(handle, type_enum) \ + do { \ + if (!handle) { \ + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_HANDLE, "Handle is null"); \ + return; \ + } \ + auto* base = reinterpret_cast(handle); \ + if (base->GetType() != type_enum) { \ + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_HANDLE, "Handle type mismatch"); \ + return; \ + } \ + } while (0) + +#define TRY_CATCH(code, return_val) \ + try { \ + code \ + } catch (const std::exception& e) { \ + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, e.what()); \ + return return_val; \ + } catch (...) { \ + SET_ERROR(GOPHER_ORCH_ERROR_UNKNOWN, "Unknown exception"); \ + return return_val; \ + } + +#define TRY_CATCH_VOID(code) \ + try { \ + code \ + } catch (const std::exception& e) { \ + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, e.what()); \ + return; \ + } catch (...) { \ + SET_ERROR(GOPHER_ORCH_ERROR_UNKNOWN, "Unknown exception"); \ + return; \ + } + +} // namespace ffi +} // namespace orch +} // namespace gopher + +#endif /* GOPHER_ORCH_FFI_BRIDGE_H */ diff --git a/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_raii.h b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_raii.h new file mode 100644 index 00000000..e598cbc1 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_raii.h @@ -0,0 +1,554 @@ +/** + * @file orch_ffi_raii.h + * @brief RAII utilities for gopher-orch C++ wrapper layer + * + * This header provides C++ RAII wrappers around the C FFI API, + * making it safe and convenient to use from C++ code while still + * going through the C API (useful for testing FFI bindings). + * + * These utilities follow the patterns from gopher-mcp C API: + * - ResourceGuard: RAII wrapper for single resources + * - AllocationTransaction: RAII wrapper for multi-resource transactions + * - ScopedCleanup: Execute cleanup on scope exit + * + * Usage: + * // Single resource with automatic cleanup + * auto json = ResourceGuard( + * gopher_orch_json_object(), + * gopher_orch_json_release); + * + * // Multi-resource transaction + * AllocationTransaction txn; + * txn.track(gopher_orch_json_object(), gopher_orch_json_release); + * txn.track(gopher_orch_json_array(), gopher_orch_json_release); + * // ... do work ... + * txn.commit(); // Ownership transferred, no cleanup on scope exit + */ + +#ifndef GOPHER_ORCH_FFI_RAII_H +#define GOPHER_ORCH_FFI_RAII_H + +#ifdef __cplusplus + +#include +#include +#include +#include +#include + +#include "orch_ffi.h" + +namespace gopher { +namespace orch { +namespace ffi { + +/* ============================================================================ + * ResourceGuard - RAII wrapper for single handle + * + * Similar to std::unique_ptr but designed for C FFI handles. + * ============================================================================ + */ + +template +class ResourceGuard { + public: + using Deleter = std::function; + + /* Default constructor - empty guard */ + ResourceGuard() : handle_(nullptr), deleter_(nullptr) {} + + /* Constructor with handle and deleter */ + ResourceGuard(T handle, Deleter deleter) + : handle_(handle), deleter_(std::move(deleter)) {} + + /* Move constructor */ + ResourceGuard(ResourceGuard&& other) noexcept + : handle_(other.handle_), deleter_(std::move(other.deleter_)) { + other.handle_ = nullptr; + } + + /* Move assignment */ + ResourceGuard& operator=(ResourceGuard&& other) noexcept { + if (this != &other) { + reset(); + handle_ = other.handle_; + deleter_ = std::move(other.deleter_); + other.handle_ = nullptr; + } + return *this; + } + + /* Disable copy */ + ResourceGuard(const ResourceGuard&) = delete; + ResourceGuard& operator=(const ResourceGuard&) = delete; + + /* Destructor - cleanup if not released */ + ~ResourceGuard() { reset(); } + + /* Get the underlying handle (does not transfer ownership) */ + T get() const { return handle_; } + + /* Implicit conversion to handle type for convenience */ + operator T() const { return handle_; } + + /* Check if guard holds a valid handle */ + explicit operator bool() const { return handle_ != nullptr; } + + /* Release ownership and return the handle */ + T release() { + T h = handle_; + handle_ = nullptr; + return h; + } + + /* Reset and cleanup current handle, optionally set new handle */ + void reset(T new_handle = nullptr, Deleter new_deleter = nullptr) { + if (handle_ && deleter_) { + deleter_(handle_); + } + handle_ = new_handle; + if (new_deleter) { + deleter_ = std::move(new_deleter); + } + } + + /* Swap with another guard */ + void swap(ResourceGuard& other) noexcept { + std::swap(handle_, other.handle_); + std::swap(deleter_, other.deleter_); + } + + private: + T handle_; + Deleter deleter_; +}; + +/* ============================================================================ + * Convenience type aliases for common handle types + * ============================================================================ + */ + +using JsonGuard = ResourceGuard; +using RunnableGuard = ResourceGuard; +using DispatcherGuard = ResourceGuard; +using ConfigGuard = ResourceGuard; +using ServerGuard = ResourceGuard; +using FsmGuard = ResourceGuard; +using GraphGuard = ResourceGuard; +using SequenceGuard = ResourceGuard; +using ParallelGuard = ResourceGuard; +using RouterGuard = ResourceGuard; +using CallbackManagerGuard = ResourceGuard; +using ApprovalHandlerGuard = ResourceGuard; +using CancelTokenGuard = ResourceGuard; +using IteratorGuard = ResourceGuard; + +/* ============================================================================ + * Factory functions for creating guarded handles + * ============================================================================ + */ + +inline JsonGuard make_json_null() { + return JsonGuard(gopher_orch_json_null(), gopher_orch_json_release); +} + +inline JsonGuard make_json_bool(gopher_orch_bool_t value) { + return JsonGuard(gopher_orch_json_bool(value), gopher_orch_json_release); +} + +inline JsonGuard make_json_int(int64_t value) { + return JsonGuard(gopher_orch_json_int(value), gopher_orch_json_release); +} + +inline JsonGuard make_json_double(double value) { + return JsonGuard(gopher_orch_json_double(value), gopher_orch_json_release); +} + +inline JsonGuard make_json_string(const char* value) { + return JsonGuard(gopher_orch_json_string(value), gopher_orch_json_release); +} + +inline JsonGuard make_json_object() { + return JsonGuard(gopher_orch_json_object(), gopher_orch_json_release); +} + +inline JsonGuard make_json_array() { + return JsonGuard(gopher_orch_json_array(), gopher_orch_json_release); +} + +inline JsonGuard parse_json(const char* json_str) { + return JsonGuard(gopher_orch_json_parse(json_str), gopher_orch_json_release); +} + +inline DispatcherGuard make_dispatcher() { + return DispatcherGuard(gopher_orch_dispatcher_create(), + gopher_orch_dispatcher_destroy); +} + +inline ConfigGuard make_config() { + return ConfigGuard(gopher_orch_config_create(), gopher_orch_config_destroy); +} + +inline SequenceGuard make_sequence() { + return SequenceGuard(gopher_orch_sequence_create(), + gopher_orch_sequence_destroy); +} + +inline ParallelGuard make_parallel() { + return ParallelGuard(gopher_orch_parallel_create(), + gopher_orch_parallel_destroy); +} + +inline RouterGuard make_router() { + return RouterGuard(gopher_orch_router_create(), gopher_orch_router_destroy); +} + +inline GraphGuard make_graph() { + return GraphGuard(gopher_orch_graph_create(), gopher_orch_graph_destroy); +} + +inline FsmGuard make_fsm(int32_t initial_state) { + return FsmGuard(gopher_orch_fsm_create(initial_state), + gopher_orch_fsm_destroy); +} + +inline CancelTokenGuard make_cancel_token() { + return CancelTokenGuard(gopher_orch_cancel_token_create(), + gopher_orch_cancel_token_destroy); +} + +inline CallbackManagerGuard make_callback_manager() { + return CallbackManagerGuard(gopher_orch_callback_manager_create(), + gopher_orch_callback_manager_destroy); +} + +/* ============================================================================ + * AllocationTransaction - RAII wrapper for multi-resource operations + * + * Ensures all-or-nothing semantics: if commit() is not called before + * destruction, all tracked resources are cleaned up. + * ============================================================================ + */ + +class AllocationTransaction { + public: + AllocationTransaction() : committed_(false) {} + + /* Disable copy */ + AllocationTransaction(const AllocationTransaction&) = delete; + AllocationTransaction& operator=(const AllocationTransaction&) = delete; + + /* Move support */ + AllocationTransaction(AllocationTransaction&& other) noexcept + : resources_(std::move(other.resources_)), committed_(other.committed_) { + other.committed_ = true; /* Prevent cleanup in moved-from object */ + } + + AllocationTransaction& operator=(AllocationTransaction&& other) noexcept { + if (this != &other) { + rollback(); + resources_ = std::move(other.resources_); + committed_ = other.committed_; + other.committed_ = true; + } + return *this; + } + + /* Destructor - rollback if not committed */ + ~AllocationTransaction() { + if (!committed_) { + rollback(); + } + } + + /** + * Track a resource for cleanup + * @param handle Resource handle + * @param deleter Cleanup function + */ + template + void track(T handle, D deleter) { + if (handle) { + resources_.emplace_back([handle, deleter]() { deleter(handle); }); + } + } + + /** + * Track a ResourceGuard (takes ownership) + */ + template + void track(ResourceGuard&& guard) { + if (guard) { + T handle = guard.release(); + /* Need to capture the deleter type-erased */ + resources_.emplace_back([handle]() { + /* This requires knowing the deleter type - use with care */ + /* For full type safety, use the track(handle, deleter) overload */ + }); + } + } + + /** + * Commit transaction - prevent cleanup + */ + void commit() { committed_ = true; } + + /** + * Rollback transaction - cleanup all resources + */ + void rollback() { + /* Cleanup in reverse order (LIFO) */ + while (!resources_.empty()) { + try { + resources_.back()(); + } catch (...) { + /* Suppress exceptions during cleanup */ + } + resources_.pop_back(); + } + committed_ = true; /* Prevent double cleanup */ + } + + /** + * Get number of tracked resources + */ + size_t size() const { return resources_.size(); } + + /** + * Check if transaction has been committed + */ + bool is_committed() const { return committed_; } + + private: + std::vector> resources_; + bool committed_; +}; + +/* ============================================================================ + * ScopedCleanup - Execute cleanup function on scope exit + * + * Use for any cleanup that doesn't fit the handle pattern. + * ============================================================================ + */ + +class ScopedCleanup { + public: + using Cleanup = std::function; + + explicit ScopedCleanup(Cleanup cleanup) + : cleanup_(std::move(cleanup)), dismissed_(false) {} + + /* Disable copy */ + ScopedCleanup(const ScopedCleanup&) = delete; + ScopedCleanup& operator=(const ScopedCleanup&) = delete; + + /* Move support */ + ScopedCleanup(ScopedCleanup&& other) noexcept + : cleanup_(std::move(other.cleanup_)), dismissed_(other.dismissed_) { + other.dismissed_ = true; + } + + ScopedCleanup& operator=(ScopedCleanup&& other) noexcept { + if (this != &other) { + execute(); + cleanup_ = std::move(other.cleanup_); + dismissed_ = other.dismissed_; + other.dismissed_ = true; + } + return *this; + } + + ~ScopedCleanup() { execute(); } + + /** + * Dismiss cleanup - prevent execution + */ + void dismiss() { dismissed_ = true; } + + /** + * Execute cleanup now (and dismiss) + */ + void execute() { + if (!dismissed_ && cleanup_) { + try { + cleanup_(); + } catch (...) { + /* Suppress exceptions */ + } + dismissed_ = true; + } + } + + private: + Cleanup cleanup_; + bool dismissed_; +}; + +/* Helper macro for scope cleanup */ +#define GOPHER_ORCH_SCOPE_EXIT(code) \ + ::gopher::orch::ffi::ScopedCleanup _scope_exit_##__LINE__([&]() { code; }) + +/* ============================================================================ + * ErrorScope - Clear error on scope entry, optionally check on exit + * ============================================================================ + */ + +class ErrorScope { + public: + ErrorScope() { gopher_orch_clear_error(); } + + ~ErrorScope() = default; + + /** + * Get last error code + */ + gopher_orch_error_t error() const { + auto info = gopher_orch_last_error(); + return info ? info->code : GOPHER_ORCH_OK; + } + + /** + * Get last error message + */ + const char* message() const { + auto info = gopher_orch_last_error(); + return info ? info->message : nullptr; + } + + /** + * Check if there was an error + */ + bool has_error() const { + auto info = gopher_orch_last_error(); + return info && info->code != GOPHER_ORCH_OK; + } + + /** + * Throw exception if there was an error + */ + void throw_if_error() const { + if (has_error()) { + throw std::runtime_error(message() ? message() : "Unknown error"); + } + } +}; + +/* ============================================================================ + * StringGuard - RAII wrapper for owned strings + * ============================================================================ + */ + +class StringGuard { + public: + StringGuard() : str_(nullptr) {} + explicit StringGuard(char* str) : str_(str) {} + + /* Disable copy */ + StringGuard(const StringGuard&) = delete; + StringGuard& operator=(const StringGuard&) = delete; + + /* Move support */ + StringGuard(StringGuard&& other) noexcept : str_(other.str_) { + other.str_ = nullptr; + } + + StringGuard& operator=(StringGuard&& other) noexcept { + if (this != &other) { + reset(); + str_ = other.str_; + other.str_ = nullptr; + } + return *this; + } + + ~StringGuard() { reset(); } + + const char* get() const { return str_; } + const char* c_str() const { return str_; } + operator const char*() const { return str_; } + explicit operator bool() const { return str_ != nullptr; } + + char* release() { + char* s = str_; + str_ = nullptr; + return s; + } + + void reset(char* new_str = nullptr) { + if (str_) { + gopher_orch_free(str_); + } + str_ = new_str; + } + + private: + char* str_; +}; + +/* Factory for JSON stringify */ +inline StringGuard stringify_json(gopher_orch_json_t json) { + return StringGuard(gopher_orch_json_stringify(json)); +} + +inline StringGuard stringify_json_pretty(gopher_orch_json_t json) { + return StringGuard(gopher_orch_json_stringify_pretty(json)); +} + +/* ============================================================================ + * Async completion helper + * ============================================================================ + */ + +/** + * SyncCompletion - Helper for blocking on async operations + * + * Usage: + * SyncCompletion completion; + * gopher_orch_runnable_invoke(runnable, input, config, dispatcher, + * nullptr, + * SyncCompletion::callback, &completion); + * dispatcher->run_until(completion.is_complete); + * auto result = completion.get_result(); + */ +template +class SyncCompletion { + public: + SyncCompletion() + : complete_(false), error_(GOPHER_ORCH_OK), result_(nullptr) {} + + /* Static callback for C API */ + static void callback(void* user_context, + gopher_orch_error_t error, + T result) noexcept { + auto* self = static_cast(user_context); + self->error_ = error; + self->result_ = result; + self->complete_ = true; + } + + bool is_complete() const { return complete_; } + gopher_orch_error_t error() const { return error_; } + T result() const { return result_; } + + /* Get result, taking ownership */ + T take_result() { + T r = result_; + result_ = nullptr; + return r; + } + + private: + std::atomic complete_; + gopher_orch_error_t error_; + T result_; +}; + +using JsonSyncCompletion = SyncCompletion; + +} // namespace ffi +} // namespace orch +} // namespace gopher + +#endif /* __cplusplus */ + +#endif /* GOPHER_ORCH_FFI_RAII_H */ diff --git a/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_types.h b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_types.h new file mode 100644 index 00000000..b94b7ee0 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/ffi/orch_ffi_types.h @@ -0,0 +1,561 @@ +/** + * @file orch_ffi_types.h + * @brief FFI-safe type definitions for gopher-orch C API + * + * This header provides FFI-safe type definitions enabling gopher-orch to be + * used from any language with C FFI support (Python, Rust, Go, Node.js, etc.). + * + * Design Principles (following gopher-mcp C API patterns): + * - All types are FFI-safe primitives or opaque handles + * - Opaque handles hide C++ implementation details + * - Clear ownership semantics: OWNED vs BORROWED annotations + * - Thread-local error handling for non-intrusive error propagation + * - JSON-to-JSON as the primary FFI boundary (type-erased) + * - Callback convention: function pointer + void* context + * + * Architecture: + * - All operations happen in dispatcher thread context + * - Callbacks are invoked in dispatcher thread + * - RAII guards ensure automatic cleanup + * - Follows Create -> Configure -> Use -> Destroy lifecycle + * + * Memory Management: + * - All handles are reference-counted internally + * - Automatic cleanup through RAII guards + * - Optional manual resource management with explicit _free() functions + * - Thread-safe resource tracking in debug mode + */ + +#ifndef GOPHER_ORCH_FFI_TYPES_H +#define GOPHER_ORCH_FFI_TYPES_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ============================================================================ + * Platform Detection and Export Macros + * ============================================================================ + */ + +#if defined(_WIN32) || defined(__CYGWIN__) +#ifdef GOPHER_ORCH_BUILDING_DLL +#define GOPHER_ORCH_API __declspec(dllexport) +#else +#define GOPHER_ORCH_API __declspec(dllimport) +#endif +#else +#if __GNUC__ >= 4 || defined(__clang__) +#define GOPHER_ORCH_API __attribute__((visibility("default"))) +#else +#define GOPHER_ORCH_API +#endif +#endif + +/* C++ noexcept compatibility */ +#ifdef __cplusplus +#define GOPHER_ORCH_NOEXCEPT noexcept +#else +#define GOPHER_ORCH_NOEXCEPT +#endif + +/* ============================================================================ + * FFI-Safe Primitive Types + * ============================================================================ + */ + +/** Boolean type - 0 = false, non-zero = true */ +typedef int32_t gopher_orch_bool_t; +#define GOPHER_ORCH_FALSE 0 +#define GOPHER_ORCH_TRUE 1 + +/** Size type for counts and lengths */ +typedef size_t gopher_orch_size_t; + +/** Duration in milliseconds */ +typedef uint64_t gopher_orch_duration_ms_t; + +/* ============================================================================ + * Opaque Handle Types + * + * All handles are pointers to implementation structs. + * NULL indicates invalid/error. + * Forward declarations hide C++ implementation details. + * + * Handles are reference-counted internally: + * - gopher_orch_*_add_ref() increments reference count + * - gopher_orch_*_release() decrements reference count + * - When count reaches 0, resource is destroyed + * ============================================================================ + */ + +/** Dispatcher handle - event loop for async operations */ +typedef struct gopher_orch_dispatcher_impl* gopher_orch_dispatcher_t; + +/** + * Runnable handle - type-erased JSON-to-JSON operation + * + * This is the core abstraction: all runnables are exposed as JSON->JSON + * transformations at the FFI boundary, regardless of their C++ template types. + */ +typedef struct gopher_orch_runnable_impl* gopher_orch_runnable_t; + +/** Server handle - MCP server connection */ +typedef struct gopher_orch_server_impl* gopher_orch_server_t; + +/** Agent handle - ReActAgent wrapper for AI orchestration */ +typedef struct gopher_orch_agent_impl* gopher_orch_agent_t; + +/** JSON value handle - wrapper around internal JSON type */ +typedef struct gopher_orch_json_impl* gopher_orch_json_t; + +/** Configuration handle - RunnableConfig wrapper */ +typedef struct gopher_orch_config_impl* gopher_orch_config_t; + +/** Callback manager handle - for observability */ +typedef struct gopher_orch_callback_manager_impl* + gopher_orch_callback_manager_t; + +/** Approval handler handle - for human-in-the-loop */ +typedef struct gopher_orch_approval_handler_impl* + gopher_orch_approval_handler_t; + +/** Sequence builder handle */ +typedef struct gopher_orch_sequence_impl* gopher_orch_sequence_t; + +/** Parallel builder handle */ +typedef struct gopher_orch_parallel_impl* gopher_orch_parallel_t; + +/** Router builder handle */ +typedef struct gopher_orch_router_impl* gopher_orch_router_t; + +/** State machine handle */ +typedef struct gopher_orch_fsm_impl* gopher_orch_fsm_t; + +/** State graph builder handle */ +typedef struct gopher_orch_graph_impl* gopher_orch_graph_t; + +/** Compiled state graph handle (runnable) */ +typedef struct gopher_orch_compiled_graph_impl* gopher_orch_compiled_graph_t; + +/** Cancellation token handle */ +typedef struct gopher_orch_cancel_token_impl* gopher_orch_cancel_token_t; + +/** Iterator handle - for collections */ +typedef struct gopher_orch_iterator_impl* gopher_orch_iterator_t; + +/** RAII guard handle - for automatic cleanup */ +typedef struct gopher_orch_guard_impl* gopher_orch_guard_t; + +/** Transaction handle - for atomic multi-resource operations */ +typedef struct gopher_orch_transaction_impl* gopher_orch_transaction_t; + +/* ============================================================================ + * Type ID Enumeration + * + * Used for runtime type checking and RAII guard type validation. + * ============================================================================ + */ + +typedef enum { + GOPHER_ORCH_TYPE_UNKNOWN = 0, + GOPHER_ORCH_TYPE_DISPATCHER = 1, + GOPHER_ORCH_TYPE_RUNNABLE = 2, + GOPHER_ORCH_TYPE_SERVER = 3, + GOPHER_ORCH_TYPE_AGENT = 4, + GOPHER_ORCH_TYPE_JSON = 5, + GOPHER_ORCH_TYPE_CONFIG = 6, + GOPHER_ORCH_TYPE_CALLBACK_MANAGER = 7, + GOPHER_ORCH_TYPE_APPROVAL_HANDLER = 8, + GOPHER_ORCH_TYPE_SEQUENCE = 9, + GOPHER_ORCH_TYPE_PARALLEL = 10, + GOPHER_ORCH_TYPE_ROUTER = 11, + GOPHER_ORCH_TYPE_FSM = 12, + GOPHER_ORCH_TYPE_GRAPH = 13, + GOPHER_ORCH_TYPE_COMPILED_GRAPH = 14, + GOPHER_ORCH_TYPE_CANCEL_TOKEN = 15, + GOPHER_ORCH_TYPE_ITERATOR = 16, + GOPHER_ORCH_TYPE_GUARD = 17, + GOPHER_ORCH_TYPE_TRANSACTION = 18, +} gopher_orch_type_id_t; + +/* ============================================================================ + * Error Codes + * + * Negative values indicate errors, zero indicates success. + * Use gopher_orch_last_error() for detailed error information. + * ============================================================================ + */ + +typedef enum { + /* Success */ + GOPHER_ORCH_OK = 0, + + /* Handle/argument errors */ + GOPHER_ORCH_ERROR_INVALID_HANDLE = -1, + GOPHER_ORCH_ERROR_INVALID_ARGUMENT = -2, + GOPHER_ORCH_ERROR_NULL_POINTER = -3, + + /* Resource errors */ + GOPHER_ORCH_ERROR_NOT_FOUND = -10, + GOPHER_ORCH_ERROR_ALREADY_EXISTS = -11, + GOPHER_ORCH_ERROR_RESOURCE_LIMIT = -12, + GOPHER_ORCH_ERROR_NO_MEMORY = -13, + + /* Connection errors */ + GOPHER_ORCH_ERROR_CONNECTION_FAILED = -20, + GOPHER_ORCH_ERROR_NOT_CONNECTED = -21, + GOPHER_ORCH_ERROR_TIMEOUT = -22, + + /* State machine errors */ + GOPHER_ORCH_ERROR_INVALID_TRANSITION = -30, + GOPHER_ORCH_ERROR_GUARD_REJECTED = -31, + GOPHER_ORCH_ERROR_INVALID_STATE = -32, + + /* Execution errors */ + GOPHER_ORCH_ERROR_CANCELLED = -40, + GOPHER_ORCH_ERROR_APPROVAL_DENIED = -41, + GOPHER_ORCH_ERROR_CIRCUIT_OPEN = -42, + GOPHER_ORCH_ERROR_FALLBACK_EXHAUSTED = -43, + + /* Parse/format errors */ + GOPHER_ORCH_ERROR_PARSE_ERROR = -50, + GOPHER_ORCH_ERROR_INVALID_JSON = -51, + + /* Internal errors */ + GOPHER_ORCH_ERROR_INTERNAL = -90, + GOPHER_ORCH_ERROR_NOT_IMPLEMENTED = -91, + GOPHER_ORCH_ERROR_UNKNOWN = -99 +} gopher_orch_error_t; + +/* ============================================================================ + * Structured Error Information + * + * Provides detailed error context via thread-local storage. + * Error messages are valid until the next API call on the same thread. + * ============================================================================ + */ + +typedef struct { + gopher_orch_error_t code; /* Error code */ + const char* message; /* BORROWED: Error message, valid until next call */ + const char* details; /* BORROWED: Additional context, may be NULL */ + const char* file; /* BORROWED: Source file where error occurred */ + int32_t line; /* Source line number */ +} gopher_orch_error_info_t; + +/* ============================================================================ + * FFI-Safe String Types + * + * Strings are passed as const char* (null-terminated, UTF-8 encoded). + * For strings returned by the API: + * - BORROWED: Valid until next API call or handle destruction + * - OWNED: Caller must free with gopher_orch_free() + * ============================================================================ + */ + +/** Non-owning string view for input parameters */ +typedef struct { + const char* data; /* UTF-8 encoded, may be NULL */ + gopher_orch_size_t length; /* Length in bytes (excluding null terminator) */ +} gopher_orch_string_view_t; + +/** Owning string buffer for output parameters */ +typedef struct { + char* data; /* UTF-8 encoded, null-terminated */ + gopher_orch_size_t length; /* Length in bytes (excluding null terminator) */ + gopher_orch_size_t capacity; /* Allocated capacity */ +} gopher_orch_string_buffer_t; + +/* ============================================================================ + * Callback Function Types + * + * All callbacks follow the pattern: function pointer + void* user_context + * Callbacks are ALWAYS invoked in the dispatcher thread context. + * + * Convention for JSON callbacks (following the FFI analysis): + * (const char* input_json, void* context) -> char* + * But we use gopher_orch_json_t handles for efficiency (avoid re-parsing). + * ============================================================================ + */ + +/** + * Generic work callback - posted to dispatcher thread + * @param user_context User-provided context data + * + * Note: noexcept is not valid on typedef function pointers in C++14. + * Callbacks should not throw exceptions across the FFI boundary. + */ +typedef void (*gopher_orch_work_fn)(void* user_context); + +/** + * Destructor callback - called when callback registration is removed + * @param user_context User-provided context to cleanup + */ +typedef void (*gopher_orch_destructor_fn)(void* user_context); + +/** + * Async completion callback for JSON results + * OWNERSHIP: result is OWNED by callback - must call gopher_orch_json_release + * + * @param user_context User-provided context data + * @param error Error code (GOPHER_ORCH_OK on success) + * @param result JSON result handle, NULL on error, OWNED by callback + */ +typedef void (*gopher_orch_completion_fn)(void* user_context, + gopher_orch_error_t error, + gopher_orch_json_t result); + +/** + * State transition observer callback + * + * @param user_context User-provided context data + * @param from_state Previous state ID + * @param to_state New state ID + * @param event Triggering event ID + */ +typedef void (*gopher_orch_transition_fn)(void* user_context, + int32_t from_state, + int32_t to_state, + int32_t event); + +/** + * State machine guard callback - return non-zero to allow transition + * + * @param user_context User-provided context data + * @param from_state Current state ID + * @param event Triggering event ID + * @return Non-zero to allow transition, zero to reject + */ +typedef int32_t (*gopher_orch_guard_fn)(void* user_context, + int32_t from_state, + int32_t event); + +/** + * State machine action callback + * + * @param user_context User-provided context data + * @param from_state Previous state ID + * @param to_state New state ID + * @param event Triggering event ID + */ +typedef void (*gopher_orch_action_fn)(void* user_context, + int32_t from_state, + int32_t to_state, + int32_t event); + +/** + * Router condition callback - return non-zero if route should be taken + * + * @param user_context User-provided context data + * @param input Input JSON value, BORROWED - do not destroy + * @return Non-zero if this route should be taken + */ +typedef int32_t (*gopher_orch_condition_fn)(void* user_context, + gopher_orch_json_t input); + +/** + * StateGraph conditional edge callback - returns destination node name + * OWNERSHIP: Returned string is BORROWED - valid only during callback + * + * @param user_context User-provided context data + * @param state Current graph state, BORROWED - do not destroy + * @return Destination node name, BORROWED, or NULL to end + */ +typedef const char* (*gopher_orch_edge_condition_fn)(void* user_context, + gopher_orch_json_t state); + +/** + * Lambda function for custom runnables + * OWNERSHIP: input is BORROWED, return value is OWNED by caller + * + * This is the core FFI pattern: (JSON input, context) -> JSON output + * + * @param user_context User-provided context data + * @param input Input JSON value, BORROWED - do not destroy + * @param out_error Output error code + * @return Result JSON value, OWNED by caller, NULL on error + */ +typedef gopher_orch_json_t (*gopher_orch_lambda_fn)( + void* user_context, + gopher_orch_json_t input, + gopher_orch_error_t* out_error); + +/** + * Approval request callback for human-in-the-loop + * + * @param user_context User-provided context data + * @param action_name Name of the action requiring approval, BORROWED + * @param preview Preview data for review, BORROWED - do not destroy + * @param prompt Human-readable prompt, BORROWED + * @param out_approved Output: set to non-zero to approve + * @param out_reason Output: reason for decision, OWNED by caller (must free) + * @param out_modifications Output: optional input modifications, OWNED (may be + * NULL) + */ +typedef void (*gopher_orch_approval_fn)(void* user_context, + const char* action_name, + gopher_orch_json_t preview, + const char* prompt, + gopher_orch_bool_t* out_approved, + char** out_reason, + gopher_orch_json_t* out_modifications); + +/** + * Chain start/end event callback + * + * @param user_context User-provided context data + * @param run_id Unique run identifier, BORROWED + * @param name Chain name, BORROWED + * @param data Input/output data, BORROWED - do not destroy + */ +typedef void (*gopher_orch_chain_event_fn)(void* user_context, + const char* run_id, + const char* name, + gopher_orch_json_t data); + +/** + * Chain error event callback + */ +typedef void (*gopher_orch_chain_error_fn)(void* user_context, + const char* run_id, + const char* name, + gopher_orch_error_t error, + const char* message); + +/** + * Tool start/end event callback + */ +typedef void (*gopher_orch_tool_event_fn)(void* user_context, + const char* run_id, + const char* tool_name, + gopher_orch_json_t data); + +/** + * Tool error event callback + */ +typedef void (*gopher_orch_tool_error_fn)(void* user_context, + const char* run_id, + const char* tool_name, + gopher_orch_error_t error, + const char* message); + +/** + * Retry event callback + */ +typedef void (*gopher_orch_retry_fn)(void* user_context, + const char* run_id, + const char* name, + gopher_orch_error_t error, + uint32_t attempt, + uint32_t max_attempts); + +/** + * Custom event callback + */ +typedef void (*gopher_orch_custom_event_fn)(void* user_context, + const char* event_name, + gopher_orch_json_t data); + +/** + * Guard cleanup callback for RAII guards + * + * @param resource Resource to cleanup + */ +typedef void (*gopher_orch_cleanup_fn)(void* resource); + +/* ============================================================================ + * Configuration Structures + * ============================================================================ + */ + +/** Retry policy configuration */ +typedef struct { + uint32_t max_attempts; /* Maximum number of attempts (1 = no retry) */ + uint64_t initial_delay_ms; /* Initial delay between retries */ + double backoff_multiplier; /* Multiplier for exponential backoff */ + uint64_t max_delay_ms; /* Maximum delay between retries */ + gopher_orch_bool_t jitter; /* Add random jitter to delays */ +} gopher_orch_retry_policy_t; + +/** Circuit breaker policy configuration */ +typedef struct { + uint32_t failure_threshold; /* Failures before opening circuit */ + uint64_t recovery_timeout_ms; /* Time before attempting half-open */ + uint32_t half_open_max_calls; /* Max calls in half-open state */ +} gopher_orch_circuit_breaker_policy_t; + +/** MCP server transport type */ +typedef enum { + GOPHER_ORCH_TRANSPORT_STDIO = 0, + GOPHER_ORCH_TRANSPORT_SSE = 1, + GOPHER_ORCH_TRANSPORT_WEBSOCKET = 2 +} gopher_orch_transport_type_t; + +/** MCP server configuration */ +typedef struct { + const char* name; /* Server name */ + gopher_orch_transport_type_t transport; + + /* Stdio transport options */ + const char* command; /* Command to execute */ + const char* const* args; /* Command arguments (NULL-terminated) */ + gopher_orch_size_t args_count; + const char* const* env_keys; /* Environment variable keys */ + const char* const* env_values; /* Environment variable values */ + gopher_orch_size_t env_count; + + /* SSE/WebSocket transport options */ + const char* url; + const char* const* header_keys; + const char* const* header_values; + gopher_orch_size_t header_count; + + /* Timeouts */ + uint64_t connect_timeout_ms; + uint64_t request_timeout_ms; +} gopher_orch_mcp_config_t; + +/** Callback handler configuration */ +typedef struct { + gopher_orch_chain_event_fn on_chain_start; + gopher_orch_chain_event_fn on_chain_end; + gopher_orch_chain_error_fn on_chain_error; + gopher_orch_tool_event_fn on_tool_start; + gopher_orch_tool_event_fn on_tool_end; + gopher_orch_tool_error_fn on_tool_error; + gopher_orch_retry_fn on_retry; + gopher_orch_custom_event_fn on_custom_event; + void* user_context; + gopher_orch_destructor_fn destructor; /* Called when handler is removed */ +} gopher_orch_callback_handler_config_t; + +/** Transaction options */ +typedef struct { + gopher_orch_bool_t auto_rollback; /* Auto-rollback if not committed */ + gopher_orch_bool_t strict_ordering; /* Cleanup in reverse order (LIFO) */ + uint32_t max_resources; /* Maximum resources (0 = unlimited) */ +} gopher_orch_transaction_opts_t; + +/** State graph node configuration */ +typedef struct { + const char* name; /* Node name */ + gopher_orch_runnable_t runnable; /* Associated runnable (may be NULL) */ + const char* output_key; /* Key to write output to state (NULL for none) */ +} gopher_orch_node_config_t; + +/** State channel type for reducers */ +typedef enum { + GOPHER_ORCH_CHANNEL_LAST_VALUE = 0, /* Keep last value */ + GOPHER_ORCH_CHANNEL_APPEND_LIST = 1, /* Append to list */ + GOPHER_ORCH_CHANNEL_MERGE_OBJECT = 2, /* Merge objects */ +} gopher_orch_channel_type_t; + +#ifdef __cplusplus +} +#endif + +#endif /* GOPHER_ORCH_FFI_TYPES_H */ diff --git a/third_party/gopher-orch/include/gopher/orch/fsm/state_machine.h b/third_party/gopher-orch/include/gopher/orch/fsm/state_machine.h new file mode 100644 index 00000000..09bff002 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/fsm/state_machine.h @@ -0,0 +1,335 @@ +#pragma once + +// StateMachine - Type-safe finite state machine +// Manages entity lifecycles with discrete states and event-driven transitions +// +// Use cases: +// - Connection states (DISCONNECTED → CONNECTING → CONNECTED → ERROR) +// - Workflow lifecycle (PENDING → RUNNING → PAUSED → COMPLETED) +// - Agent behavior (IDLE → THINKING → ACTING → WAITING) + +#include +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +namespace fsm { + +using namespace gopher::orch::core; + +// ============================================================================= +// StateMachine - Type-safe finite state machine +// ============================================================================= + +template +class StateMachine { + public: + using StateType = TState; + using EventType = TEvent; + using ContextType = TContext; + + // Guard: returns true if transition is allowed + using Guard = + std::function; + + // Action: executed during transition + using TransitionAction = + std::function; + + // State callbacks: executed on entry/exit + using StateAction = std::function; + + // Observer: notified of all state changes + using StateObserver = + std::function; + + // Async transition callback + using TransitionCallback = std::function)>; + + explicit StateMachine(TState initial_state) : current_state_(initial_state) {} + + // ========================================================================= + // Configuration (Builder pattern) + // ========================================================================= + + // Add a valid transition: from --[event]--> to + StateMachine& addTransition(TState from, TEvent event, TState to) { + transitions_[{from, event}] = to; + return *this; + } + + // Add guard condition for a transition + // Guard must return true for transition to proceed + StateMachine& setGuard(TState from, TEvent event, Guard guard) { + guards_[{from, event}] = std::move(guard); + return *this; + } + + // Add action to execute during transition (after exit, before enter) + StateMachine& setAction(TState from, TEvent event, TransitionAction action) { + actions_[{from, event}] = std::move(action); + return *this; + } + + // Set callback when entering a state + StateMachine& onEnter(TState state, StateAction callback) { + on_enter_[state] = std::move(callback); + return *this; + } + + // Set callback when exiting a state + StateMachine& onExit(TState state, StateAction callback) { + on_exit_[state] = std::move(callback); + return *this; + } + + // Set global state change observer (for logging/tracing) + StateMachine& onStateChange(StateObserver observer) { + state_observer_ = std::move(observer); + return *this; + } + + // ========================================================================= + // State Query + // ========================================================================= + + TState currentState() const { return current_state_; } + + bool isInState(TState state) const { return current_state_ == state; } + + // Check if an event can trigger a transition from current state + bool canTrigger(TEvent event) const { + return canTriggerWith(event, context_); + } + + bool canTriggerWith(TEvent event, const TContext& ctx) const { + auto key = std::make_pair(current_state_, event); + + // Check if transition exists + auto trans_it = transitions_.find(key); + if (trans_it == transitions_.end()) { + return false; + } + + // Check guard if present + auto guard_it = guards_.find(key); + if (guard_it != guards_.end()) { + return guard_it->second(current_state_, event, ctx); + } + + return true; + } + + // Get list of valid events from current state + std::vector validEvents() const { + std::vector events; + for (const auto& entry : transitions_) { + if (entry.first.first == current_state_) { + if (canTrigger(entry.first.second)) { + events.push_back(entry.first.second); + } + } + } + return events; + } + + // ========================================================================= + // Synchronous Trigger + // ========================================================================= + + Result trigger(TEvent event) { return triggerWith(event, context_); } + + Result triggerWith(TEvent event, TContext& ctx) { + auto key = std::make_pair(current_state_, event); + + // Find transition + auto trans_it = transitions_.find(key); + if (trans_it == transitions_.end()) { + return makeOrchError( + OrchError::INVALID_TRANSITION, + "No transition defined for event in current state"); + } + + // Check guard + auto guard_it = guards_.find(key); + if (guard_it != guards_.end() && + !guard_it->second(current_state_, event, ctx)) { + return makeOrchError(OrchError::GUARD_REJECTED, + "Transition guard returned false"); + } + + TState from_state = current_state_; + TState to_state = trans_it->second; + + // Execute exit callback + auto exit_it = on_exit_.find(from_state); + if (exit_it != on_exit_.end()) { + exit_it->second(from_state, ctx); + } + + // Execute transition action + auto action_it = actions_.find(key); + if (action_it != actions_.end()) { + action_it->second(from_state, to_state, event, ctx); + } + + // Update state + current_state_ = to_state; + + // Execute enter callback + auto enter_it = on_enter_.find(to_state); + if (enter_it != on_enter_.end()) { + enter_it->second(to_state, ctx); + } + + // Notify observer + if (state_observer_) { + state_observer_(from_state, to_state, event); + } + + return makeSuccess(to_state); + } + + // ========================================================================= + // Async Trigger (Dispatcher Integration) + // ========================================================================= + + void triggerAsync(TEvent event, + Dispatcher& dispatcher, + TransitionCallback callback) { + dispatcher.post([this, event, callback = std::move(callback)]() { + callback(trigger(event)); + }); + } + + void triggerAsyncWith(TEvent event, + TContext& ctx, + Dispatcher& dispatcher, + TransitionCallback callback) { + dispatcher.post([this, event, &ctx, callback = std::move(callback)]() { + callback(triggerWith(event, ctx)); + }); + } + + // ========================================================================= + // Context Management + // ========================================================================= + + void setContext(TContext ctx) { context_ = std::move(ctx); } + TContext& context() { return context_; } + const TContext& context() const { return context_; } + + // ========================================================================= + // Reset + // ========================================================================= + + void reset(TState state) { current_state_ = state; } + + void reset(TState state, TContext ctx) { + current_state_ = state; + context_ = std::move(ctx); + } + + private: + using TransitionKey = std::pair; + + // Custom comparator for pair keys (works with enums) + struct PairCompare { + bool operator()(const TransitionKey& a, const TransitionKey& b) const { + if (static_cast(a.first) != static_cast(b.first)) { + return static_cast(a.first) < static_cast(b.first); + } + return static_cast(a.second) < static_cast(b.second); + } + }; + + TState current_state_; + TContext context_; + + std::map transitions_; + std::map guards_; + std::map actions_; + std::map on_enter_; + std::map on_exit_; + StateObserver state_observer_; +}; + +// ============================================================================= +// StateMachineBuilder - Fluent builder for state machines +// ============================================================================= + +template +class StateMachineBuilder { + public: + using Machine = StateMachine; + + explicit StateMachineBuilder(TState initial_state) + : machine_(std::make_shared(initial_state)) {} + + // Add a transition + StateMachineBuilder& transition(TState from, TEvent event, TState to) { + machine_->addTransition(from, event, to); + return *this; + } + + // Set guard for last added transition + StateMachineBuilder& withGuard(TState from, + TEvent event, + typename Machine::Guard guard) { + machine_->setGuard(from, event, std::move(guard)); + return *this; + } + + // Set action for last added transition + StateMachineBuilder& withAction(TState from, + TEvent event, + typename Machine::TransitionAction action) { + machine_->setAction(from, event, std::move(action)); + return *this; + } + + // Set entry callback for a state + StateMachineBuilder& onEnter(TState state, + typename Machine::StateAction callback) { + machine_->onEnter(state, std::move(callback)); + return *this; + } + + // Set exit callback for a state + StateMachineBuilder& onExit(TState state, + typename Machine::StateAction callback) { + machine_->onExit(state, std::move(callback)); + return *this; + } + + // Set state change observer + StateMachineBuilder& onStateChange(typename Machine::StateObserver observer) { + machine_->onStateChange(std::move(observer)); + return *this; + } + + // Build the state machine + std::shared_ptr build() { return machine_; } + + // Implicit conversion + operator std::shared_ptr() { return build(); } + + private: + std::shared_ptr machine_; +}; + +// Factory function for creating state machine builder +template +StateMachineBuilder makeStateMachine( + TState initial_state) { + return StateMachineBuilder(initial_state); +} + +} // namespace fsm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/graph/compiled_graph.h b/third_party/gopher-orch/include/gopher/orch/graph/compiled_graph.h new file mode 100644 index 00000000..546377b7 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/graph/compiled_graph.h @@ -0,0 +1,190 @@ +#pragma once + +// CompiledStateGraph - Executable state graph +// +// Implements the Pregel model execution: +// 1. PLAN: Determine which nodes can execute +// 2. EXECUTE: Run scheduled nodes +// 3. UPDATE: Apply state changes atomically, prepare next step +// +// Design principles: +// - Async execution through dispatcher +// - Maximum iteration protection to prevent infinite loops +// - Clean error propagation +// - Composable with other Runnables via Runnable interface + +#include +#include +#include + +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/graph/graph_node.h" +#include "gopher/orch/graph/graph_state.h" + +namespace gopher { +namespace orch { +namespace graph { + +// ============================================================================= +// CompiledStateGraph - Executable state graph (Runnable implementation) +// ============================================================================= +// +// CompiledStateGraph is created by calling StateGraph::compile(). +// It implements the Runnable interface, allowing it to be composed with +// other runnables (Sequence, Parallel, Router, etc.). +// +// Execution model: +// - Takes JsonValue input, converts to GraphState +// - Executes nodes following edges until END is reached +// - Returns final GraphState as JsonValue +// +// Error handling: +// - Node errors propagate immediately, stopping execution +// - Missing entry point or nodes are validation errors +// - Maximum iterations exceeded is a runtime error + +class CompiledStateGraph + : public core::Runnable { + public: + using EdgeCondition = std::function; + + // Maximum number of node executions before aborting + // Prevents infinite loops in cyclic graphs + static constexpr size_t MAX_ITERATIONS = 100; + + // Special node name indicating graph termination + // Using static method for C++14 compatibility + static const std::string& END() { + static const std::string end_node = "__end__"; + return end_node; + } + + // Special node name indicating graph start (entry point marker) + static const std::string& START() { + static const std::string start_node = "__start__"; + return start_node; + } + + // Construct from graph components + // Should only be called by StateGraph::compile() + CompiledStateGraph(std::map> nodes, + std::map edges, + std::map conditional_edges, + std::string entry_point) + : nodes_(std::move(nodes)), + edges_(std::move(edges)), + conditional_edges_(std::move(conditional_edges)), + entry_point_(std::move(entry_point)) {} + + std::string name() const override { return "CompiledStateGraph"; } + + void invoke(const core::JsonValue& input, + const core::RunnableConfig& config, + core::Dispatcher& dispatcher, + Callback callback) override { + if (entry_point_.empty()) { + dispatcher.post([callback = std::move(callback)]() { + callback(core::makeOrchError( + core::OrchError::INVALID_ARGUMENT, + "StateGraph entry point not set")); + }); + return; + } + + // Initialize state from input + GraphState initial_state = GraphState::fromJson(input); + + // Start execution from entry point + executeNode(entry_point_, initial_state, config, dispatcher, 0, + std::move(callback)); + } + + private: + // Execute a single node and continue to the next + // This is the core Pregel step implementation + void executeNode(const std::string& node_name, + const GraphState& state, + const core::RunnableConfig& config, + core::Dispatcher& dispatcher, + size_t iteration, + Callback callback) { + // Check termination conditions + if (node_name.empty() || node_name == END()) { + dispatcher.post([state, callback = std::move(callback)]() { + callback(core::makeSuccess(state.toJson())); + }); + return; + } + + // Guard against infinite loops + if (iteration >= MAX_ITERATIONS) { + dispatcher.post([callback = std::move(callback)]() { + callback(core::makeOrchError( + core::OrchError::INTERNAL_ERROR, "Maximum iterations exceeded")); + }); + return; + } + + // Find the node to execute + auto it = nodes_.find(node_name); + if (it == nodes_.end()) { + dispatcher.post([node_name, callback = std::move(callback)]() { + callback(core::makeOrchError( + core::OrchError::INVALID_ARGUMENT, "Node not found: " + node_name)); + }); + return; + } + + // Execute the node asynchronously + // Capture self via shared_ptr to extend lifetime through callbacks + auto self = + std::static_pointer_cast(shared_from_this()); + + it->second->invoke( + state, config.child(), dispatcher, + [self, node_name, config, &dispatcher, iteration, + callback = std::move(callback)](Result result) mutable { + if (mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + return; + } + + // Get updated state and determine next node + const auto& new_state = mcp::get(result); + std::string next_node = self->getNextNode(node_name, new_state); + + // Continue execution with the next node + self->executeNode(next_node, new_state, config, dispatcher, + iteration + 1, std::move(callback)); + }); + } + + // Determine the next node to execute based on edges + // Priority: conditional edges > direct edges > END + std::string getNextNode(const std::string& from, + const GraphState& state) const { + // Check conditional edges first (higher priority) + auto cond_it = conditional_edges_.find(from); + if (cond_it != conditional_edges_.end()) { + return cond_it->second(state); + } + + // Fall back to direct edges + auto edge_it = edges_.find(from); + if (edge_it != edges_.end()) { + return edge_it->second; + } + + // No outgoing edge means termination + return END(); + } + + std::map> nodes_; + std::map edges_; + std::map conditional_edges_; + std::string entry_point_; +}; + +} // namespace graph +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/graph/graph_node.h b/third_party/gopher-orch/include/gopher/orch/graph/graph_node.h new file mode 100644 index 00000000..83851f9b --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/graph/graph_node.h @@ -0,0 +1,56 @@ +#pragma once + +// GraphNode - A node in the state graph +// +// GraphNode wraps a processing function that transforms GraphState. +// It can be created from: +// - A synchronous lambda: (GraphState) -> GraphState +// - An async Runnable: JsonRunnablePtr +// +// All node execution is async through the dispatcher. + +#include +#include +#include + +#include "gopher/orch/core/config.h" +#include "gopher/orch/core/types.h" +#include "gopher/orch/graph/graph_state.h" + +namespace gopher { +namespace orch { +namespace graph { + +using namespace gopher::orch::core; + +// ============================================================================= +// GraphNode - A node in the state graph +// ============================================================================= + +class GraphNode { + public: + using NodeFunc = std::function; + + GraphNode(const std::string& name, NodeFunc func) + : name_(name), func_(std::move(func)) {} + + const std::string& name() const { return name_; } + + void invoke(const GraphState& state, + const RunnableConfig& config, + Dispatcher& dispatcher, + GraphStateCallback callback) { + func_(state, config, dispatcher, std::move(callback)); + } + + private: + std::string name_; + NodeFunc func_; +}; + +} // namespace graph +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/graph/graph_state.h b/third_party/gopher-orch/include/gopher/orch/graph/graph_state.h new file mode 100644 index 00000000..edbfdecd --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/graph/graph_state.h @@ -0,0 +1,277 @@ +#pragma once + +// GraphState - State container for StateGraph workflows +// +// Design principles: +// - Channel-based state management with optional reducers +// - Version tracking for change detection +// - JSON serialization for persistence and debugging +// - Thread-safe for concurrent node execution in Pregel model + +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +namespace graph { + +using namespace gopher::orch::core; + +// ============================================================================= +// StateChannel - Manages a single piece of state with optional reducer +// ============================================================================= +// +// Reducers enable accumulating results from multiple parallel nodes. +// Without a reducer, last-write-wins semantics apply. +// +// Example reducers: +// - Append reducer for messages: [](a, b) { return concat(a, b); } +// - Max reducer for scores: [](a, b) { return max(a, b); } +// - Merge reducer for objects: [](a, b) { return merge(a, b); } + +template +class StateChannel { + public: + using Reducer = std::function; + + // Default constructor: last-write-wins semantics + StateChannel() : has_value_(false), version_(0), reducer_(nullptr) {} + + // Constructor with reducer: values are combined using the reducer function + explicit StateChannel(Reducer reducer) + : has_value_(false), version_(0), reducer_(std::move(reducer)) {} + + // Apply an update to this channel + // If a reducer is set and we have a previous value, combine them + // Otherwise, just store the new value + void update(const T& new_value) { + if (reducer_ && has_value_) { + value_ = reducer_(value_, new_value); + } else { + value_ = new_value; + has_value_ = true; + } + version_++; + } + + // Get the current value + const T& value() const { return value_; } + + // Check if this channel has been set + bool hasValue() const { return has_value_; } + + // Get the version number (incremented on each update) + uint64_t version() const { return version_; } + + // Reset the channel to its initial state + void reset() { + value_ = T(); + has_value_ = false; + version_ = 0; + } + + private: + T value_; + bool has_value_; + uint64_t version_; + Reducer reducer_; +}; + +// ============================================================================= +// JsonReducer - Common reducers for JsonValue channels +// ============================================================================= + +namespace reducers { + +// Last-write-wins (default behavior) +inline JsonValue lastWriteWins(const JsonValue& /* old_value */, + const JsonValue& new_value) { + return new_value; +} + +// Append arrays: [1, 2] + [3, 4] = [1, 2, 3, 4] +inline JsonValue appendArray(const JsonValue& old_value, + const JsonValue& new_value) { + if (!old_value.isArray() || !new_value.isArray()) { + return new_value; + } + JsonValue result = JsonValue::array(); + for (size_t i = 0; i < old_value.size(); ++i) { + result.push_back(old_value[i]); + } + for (size_t i = 0; i < new_value.size(); ++i) { + result.push_back(new_value[i]); + } + return result; +} + +// Merge objects (shallow): {a: 1} + {b: 2} = {a: 1, b: 2} +inline JsonValue mergeObjects(const JsonValue& old_value, + const JsonValue& new_value) { + if (!old_value.isObject() || !new_value.isObject()) { + return new_value; + } + JsonValue result = old_value; + for (const auto& key : new_value.keys()) { + result[key] = new_value[key]; + } + return result; +} + +} // namespace reducers + +// ============================================================================= +// ChannelConfig - Configuration for a state channel +// ============================================================================= + +struct ChannelConfig { + using Reducer = std::function; + + // Optional reducer function for combining values + Reducer reducer; + + // Default value when channel is not set + JsonValue default_value; + + ChannelConfig() : reducer(nullptr), default_value(JsonValue::null()) {} + + explicit ChannelConfig(Reducer r) + : reducer(std::move(r)), default_value(JsonValue::null()) {} + + ChannelConfig(Reducer r, JsonValue def) + : reducer(std::move(r)), default_value(std::move(def)) {} +}; + +// ============================================================================= +// GraphState - Container for all state channels +// ============================================================================= +// +// GraphState holds all the data flowing through a StateGraph. +// Each key maps to a channel that can have an optional reducer. +// +// Lifecycle: +// 1. Create from input JSON +// 2. Nodes read state, produce updates +// 3. Updates are merged (using reducers if configured) +// 4. Final state is serialized to JSON + +class GraphState { + public: + using Reducer = std::function; + + GraphState() = default; + + // Configure a channel with a reducer + // Must be called before any updates to that channel + void configureChannel(const std::string& key, Reducer reducer) { + reducers_[key] = std::move(reducer); + } + + // Configure a channel with default value + void configureChannel(const std::string& key, + Reducer reducer, + const JsonValue& default_value) { + reducers_[key] = std::move(reducer); + channels_[key] = default_value; + versions_[key] = 0; + } + + // Set a value by key (applies reducer if configured) + void set(const std::string& key, const JsonValue& value) { + auto reducer_it = reducers_.find(key); + auto existing_it = channels_.find(key); + + if (reducer_it != reducers_.end() && existing_it != channels_.end() && + reducer_it->second) { + // Apply reducer to combine old and new values + channels_[key] = reducer_it->second(existing_it->second, value); + } else { + // Last-write-wins + channels_[key] = value; + } + versions_[key]++; + } + + // Get a value by key (returns null if not found) + JsonValue get(const std::string& key) const { + auto it = channels_.find(key); + if (it == channels_.end()) { + return JsonValue::null(); + } + return it->second; + } + + // Check if key exists + bool has(const std::string& key) const { + return channels_.find(key) != channels_.end(); + } + + // Get version of a key (0 if never set) + uint64_t version(const std::string& key) const { + auto it = versions_.find(key); + return it != versions_.end() ? it->second : 0; + } + + // Get all keys + std::vector keys() const { + std::vector result; + result.reserve(channels_.size()); + for (const auto& entry : channels_) { + result.push_back(entry.first); + } + return result; + } + + // Serialize to JSON + JsonValue toJson() const { + JsonValue result = JsonValue::object(); + for (const auto& entry : channels_) { + result[entry.first] = entry.second; + } + return result; + } + + // Deserialize from JSON + static GraphState fromJson(const JsonValue& json) { + GraphState state; + if (json.isObject()) { + for (const auto& key : json.keys()) { + state.channels_[key] = json[key]; + state.versions_[key] = 1; + } + } + return state; + } + + // Merge another state into this one (respects reducers) + void merge(const GraphState& other) { + for (const auto& entry : other.channels_) { + set(entry.first, entry.second); + } + } + + // Create a copy with the same reducer configuration + GraphState copy() const { + GraphState result; + result.channels_ = channels_; + result.versions_ = versions_; + result.reducers_ = reducers_; + return result; + } + + private: + std::map channels_; + std::map versions_; + std::map reducers_; +}; + +// Callback type for graph node completion +using GraphStateCallback = std::function)>; + +} // namespace graph +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/graph/state_graph.h b/third_party/gopher-orch/include/gopher/orch/graph/state_graph.h new file mode 100644 index 00000000..2f4d4e67 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/graph/state_graph.h @@ -0,0 +1,187 @@ +#pragma once + +// StateGraph - Stateful workflow graphs (LangGraph-inspired) +// +// Implements the Pregel model (Bulk Synchronous Parallel): +// 1. PLAN: Determine which nodes can execute +// 2. EXECUTE: Run scheduled nodes +// 3. UPDATE: Apply state changes atomically, prepare next step +// +// Usage: +// StateGraph graph; +// graph.addNode("start", [](const GraphState& s) { ... }) +// .addNode("process", processRunnable) +// .addEdge("start", "process") +// .addEdge("process", StateGraph::END()) +// .setEntryPoint("start"); +// auto compiled = graph.compile(); +// compiled->invoke(input, config, dispatcher, callback); + +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/graph/compiled_graph.h" +#include "gopher/orch/graph/graph_node.h" +#include "gopher/orch/graph/graph_state.h" + +namespace gopher { +namespace orch { +namespace graph { + +using namespace gopher::orch::core; + +// ============================================================================= +// StateGraph - Builder for stateful workflow graphs +// ============================================================================= +// +// StateGraph provides a fluent API for building workflow graphs: +// - addNode(): Add processing nodes +// - addEdge(): Add direct transitions between nodes +// - addConditionalEdge(): Add conditional transitions based on state +// - setEntryPoint(): Define the starting node +// - compile(): Create an executable CompiledStateGraph +// +// The compiled graph implements Runnable, so it can +// be composed with Sequence, Parallel, Router, and resilience wrappers. + +class StateGraph { + public: + // Condition function that evaluates state and returns next node name + using EdgeCondition = std::function; + + // Special node name for graph termination + // Using static method for C++14 compatibility (inline variables are C++17) + static const std::string& END() { + static const std::string end_node = "__end__"; + return end_node; + } + + // Special node name for graph start (can be used in edges from START) + static const std::string& START() { + static const std::string start_node = "__start__"; + return start_node; + } + + StateGraph() = default; + + // ------------------------------------------------------------------------- + // Node Addition + // ------------------------------------------------------------------------- + + // Add a node with a JsonRunnable + // The runnable receives the full state as JSON and returns updates + StateGraph& addNode(const std::string& name, JsonRunnablePtr runnable) { + auto node_func = [runnable]( + const GraphState& state, const RunnableConfig& config, + Dispatcher& dispatcher, GraphStateCallback callback) { + runnable->invoke( + state.toJson(), config, dispatcher, + [state, callback = std::move(callback)](Result result) { + if (mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + return; + } + + // Merge runnable output into state + // Output keys overwrite existing state keys + GraphState new_state = state; + const auto& output = mcp::get(result); + if (output.isObject()) { + for (const auto& key : output.keys()) { + new_state.set(key, output[key]); + } + } + callback(makeSuccess(std::move(new_state))); + }); + }; + + nodes_[name] = std::make_shared(name, std::move(node_func)); + return *this; + } + + // Add a node with a synchronous lambda function + // The lambda receives current state and returns updated state + StateGraph& addNode(const std::string& name, + std::function func) { + auto node_func = [func](const GraphState& state, const RunnableConfig&, + Dispatcher& dispatcher, + GraphStateCallback callback) { + // Post to dispatcher to maintain async semantics + // This ensures callbacks are always invoked in dispatcher context + dispatcher.post([func, state, callback = std::move(callback)]() { + try { + GraphState result = func(state); + callback(makeSuccess(std::move(result))); + } catch (const std::exception& e) { + callback(makeOrchError( + OrchError::INTERNAL_ERROR, + std::string("Node execution error: ") + e.what())); + } + }); + }; + + nodes_[name] = std::make_shared(name, std::move(node_func)); + return *this; + } + + // Add a node with an async lambda function + // The lambda receives state and callback, must invoke callback exactly once + StateGraph& addNodeAsync(const std::string& name, GraphNode::NodeFunc func) { + nodes_[name] = std::make_shared(name, std::move(func)); + return *this; + } + + // ------------------------------------------------------------------------- + // Edge Addition + // ------------------------------------------------------------------------- + + // Add a direct edge (always transitions from -> to) + StateGraph& addEdge(const std::string& from, const std::string& to) { + edges_[from] = to; + return *this; + } + + // Add a conditional edge (transitions based on state evaluation) + // The condition function returns the name of the next node + StateGraph& addConditionalEdge(const std::string& from, + EdgeCondition condition) { + conditional_edges_[from] = std::move(condition); + return *this; + } + + // ------------------------------------------------------------------------- + // Graph Configuration + // ------------------------------------------------------------------------- + + // Set the entry point node (first node to execute) + StateGraph& setEntryPoint(const std::string& node) { + entry_point_ = node; + return *this; + } + + // ------------------------------------------------------------------------- + // Compilation + // ------------------------------------------------------------------------- + + // Compile the graph into an executable form + // Returns a CompiledStateGraph that implements Runnable + std::shared_ptr compile() { + return std::make_shared( + nodes_, edges_, conditional_edges_, entry_point_); + } + + private: + std::map> nodes_; + std::map edges_; + std::map conditional_edges_; + std::string entry_point_; + + friend class CompiledStateGraph; +}; + +} // namespace graph +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/human/approval.h b/third_party/gopher-orch/include/gopher/orch/human/approval.h new file mode 100644 index 00000000..48bfe102 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/human/approval.h @@ -0,0 +1,464 @@ +#pragma once + +// HumanApproval - Human-in-the-loop approval gate for Runnable operations +// +// This module provides a way to pause execution and request human approval +// before proceeding with sensitive or irreversible operations. +// +// The approval flow: +// 1. HumanApproval wraps an inner Runnable +// 2. When invoked, it creates an ApprovalRequest with preview and context +// 3. The ApprovalHandler is called to get human decision +// 4. If approved, the inner Runnable is invoked (possibly with modifications) +// 5. If denied, an error is returned +// +// Usage: +// auto handler = std::make_shared([](auto& req) { +// // Show UI, get decision... +// return ApprovalResponse{true, "Approved by user"}; +// }); +// +// auto protected_op = HumanApproval::create( +// dangerous_operation, +// handler, +// "This operation will modify production data. Continue?" +// ); + +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +namespace human { + +// ============================================================================= +// ApprovalRequest - Information sent for human review +// ============================================================================= + +// ApprovalRequest contains all the context a human needs to make a decision. +// It includes: +// - action_name: What operation is being performed +// - preview: A preview of what will happen (input data, expected effects) +// - prompt: A human-readable question/message +// - metadata: Additional context (tags, source, urgency, etc.) +struct ApprovalRequest { + std::string action_name; // Name of the action requiring approval + core::JsonValue preview; // Preview of input/effects for review + std::string prompt; // Human-readable prompt/question + core::JsonValue metadata; // Additional context + + ApprovalRequest() + : preview(core::JsonValue::object()), + metadata(core::JsonValue::object()) {} +}; + +// ============================================================================= +// ApprovalResponse - Human decision +// ============================================================================= + +// ApprovalResponse contains the human's decision and any modifications. +// The modifications field allows the human to adjust the input before +// the operation proceeds (e.g., correcting parameters, reducing scope). +struct ApprovalResponse { + bool approved; // True if the operation should proceed + std::string reason; // Explanation for the decision + core::JsonValue modifications; // Optional modifications to input + + ApprovalResponse() : approved(false), modifications(core::JsonValue()) {} + + // Factory methods for common responses + static ApprovalResponse approve(const std::string& reason = "Approved") { + ApprovalResponse resp; + resp.approved = true; + resp.reason = reason; + return resp; + } + + static ApprovalResponse deny(const std::string& reason = "Denied") { + ApprovalResponse resp; + resp.approved = false; + resp.reason = reason; + return resp; + } + + static ApprovalResponse approveWithModifications( + const core::JsonValue& mods, + const std::string& reason = "Approved with modifications") { + ApprovalResponse resp; + resp.approved = true; + resp.reason = reason; + resp.modifications = mods; + return resp; + } +}; + +// ============================================================================= +// ApprovalHandler - Interface for requesting human approval +// ============================================================================= + +// ApprovalHandler is the interface for different approval mechanisms. +// Implementations might: +// - Show a CLI prompt +// - Display a GUI dialog +// - Send a notification and wait for response +// - Use an automated approval system (for testing) +// +// The callback-based API allows async approval (e.g., waiting for external +// response). +class ApprovalHandler { + public: + virtual ~ApprovalHandler() = default; + + // Request approval from a human. + // The callback must be invoked exactly once with the response. + // Implementations should ensure the callback is eventually called, + // even on timeout (with approved=false). + virtual void requestApproval( + const ApprovalRequest& request, + std::function callback) = 0; +}; + +// ============================================================================= +// HumanApproval - Wrap a runnable with human approval gate +// ============================================================================= + +// HumanApproval wraps an inner Runnable and gates it with human approval. +// The approval flow is: +// 1. Create ApprovalRequest with preview of the input +// 2. Call ApprovalHandler::requestApproval +// 3. On approval: invoke inner Runnable (with modifications if provided) +// 4. On denial: return error with reason +// +// Thread safety: The approval callback may be invoked on any thread. +// The inner Runnable invoke is always called on the dispatcher thread. +template +class HumanApproval : public core::Runnable { + public: + using Ptr = std::shared_ptr>; + using InnerPtr = typename core::Runnable::Ptr; + + HumanApproval(InnerPtr inner, + std::shared_ptr handler, + std::string prompt) + : inner_(std::move(inner)), + handler_(std::move(handler)), + prompt_(std::move(prompt)) {} + + std::string name() const override { + return "HumanApproval(" + inner_->name() + ")"; + } + + void invoke(const TInput& input, + const core::RunnableConfig& config, + core::Dispatcher& dispatcher, + core::ResultCallback callback) override { + // Build the approval request + ApprovalRequest request; + request.action_name = inner_->name(); + request.preview = toJsonPreview(input); + request.prompt = prompt_; + + // Capture what we need for the callback + // Use static_pointer_cast to get the correct type since we inherit + // enable_shared_from_this from Runnable base class + auto self = std::static_pointer_cast>( + this->shared_from_this()); + auto inner = inner_; + auto cfg = config; + + // Request approval (may be async) + handler_->requestApproval( + request, [self, inner, cfg, &dispatcher, callback, + input](ApprovalResponse response) mutable { + if (!response.approved) { + // Denied - post error to dispatcher + dispatcher.post([callback, response]() { + callback(core::Result(core::Error( + core::OrchError::APPROVAL_DENIED, response.reason))); + }); + return; + } + + // Approved - invoke inner runnable + // Apply modifications if provided + TInput final_input = input; + if (!response.modifications.isNull()) { + final_input = + self->fromJsonModifications(input, response.modifications); + } + + // Post invoke to dispatcher to ensure we're in the right context + dispatcher.post([inner, final_input, cfg, &dispatcher, callback]() { + inner->invoke(final_input, cfg, dispatcher, std::move(callback)); + }); + }); + } + + // Factory method + static Ptr create(InnerPtr inner, + std::shared_ptr handler, + const std::string& prompt) { + return std::make_shared>( + std::move(inner), std::move(handler), prompt); + } + + protected: + // Convert input to JSON for preview + // Default implementation works for JsonValue inputs + // Override this for custom preview formatting with non-JSON types + virtual core::JsonValue toJsonPreview(const TInput& input) { + return toJsonImpl(input); + } + + // Apply modifications to input + // Default implementation works for JsonValue inputs + // Override this for custom modification handling with non-JSON types + virtual TInput fromJsonModifications(const TInput& original, + const core::JsonValue& mods) { + (void)original; + return fromJsonImpl(mods); + } + + private: + // Type-specific JSON conversion helpers + // These use SFINAE to handle JsonValue vs other types + + // For JsonValue inputs, just return as-is + template + typename std::enable_if::value, + core::JsonValue>::type + toJsonImpl(const T& input) const { + return input; + } + + // For non-JsonValue inputs, attempt construction + template + typename std::enable_if::value, + core::JsonValue>::type + toJsonImpl(const T& input) const { + return core::JsonValue(input); + } + + // For JsonValue outputs, just return as-is + template + typename std::enable_if::value, T>::type + fromJsonImpl(const core::JsonValue& json) const { + return json; + } + + // For non-JsonValue outputs, this is a placeholder that will fail at compile + // time Users should override fromJsonModifications for non-JsonValue types + template + typename std::enable_if::value, T>::type + fromJsonImpl(const core::JsonValue& json) const { + // This static_assert provides a clear error message + static_assert(std::is_same::value, + "HumanApproval with non-JsonValue types requires " + "overriding fromJsonModifications()"); + (void)json; + return T{}; + } + + InnerPtr inner_; + std::shared_ptr handler_; + std::string prompt_; +}; + +// ============================================================================= +// CallbackApprovalHandler - Use a callback for approval +// ============================================================================= + +// CallbackApprovalHandler uses a synchronous callback function to make +// approval decisions. This is useful for: +// - Testing with deterministic approval logic +// - Simple CLI prompts +// - Automated approval based on rules +class CallbackApprovalHandler : public ApprovalHandler { + public: + // Callback type: takes request, returns response + using ApprovalCallback = + std::function; + + explicit CallbackApprovalHandler(ApprovalCallback callback) + : callback_(std::move(callback)) {} + + void requestApproval( + const ApprovalRequest& request, + std::function callback) override { + // Invoke the callback synchronously + ApprovalResponse response = callback_(request); + callback(std::move(response)); + } + + private: + ApprovalCallback callback_; +}; + +// ============================================================================= +// AsyncCallbackApprovalHandler - Use an async callback for approval +// ============================================================================= + +// AsyncCallbackApprovalHandler allows fully async approval decisions. +// The callback receives both the request and a response callback. +class AsyncCallbackApprovalHandler : public ApprovalHandler { + public: + using AsyncApprovalCallback = std::function)>; + + explicit AsyncCallbackApprovalHandler(AsyncApprovalCallback callback) + : callback_(std::move(callback)) {} + + void requestApproval( + const ApprovalRequest& request, + std::function callback) override { + callback_(request, std::move(callback)); + } + + private: + AsyncApprovalCallback callback_; +}; + +// ============================================================================= +// AutoApprovalHandler - Automatically approves (for testing) +// ============================================================================= + +// AutoApprovalHandler automatically approves all requests. +// Use this for: +// - Unit testing the approval flow +// - Development/staging environments +// - Non-sensitive operations that still need the approval interface +class AutoApprovalHandler : public ApprovalHandler { + public: + explicit AutoApprovalHandler(const std::string& reason = "Auto-approved") + : reason_(reason) {} + + void requestApproval( + const ApprovalRequest& request, + std::function callback) override { + (void)request; + callback(ApprovalResponse::approve(reason_)); + } + + private: + std::string reason_; +}; + +// ============================================================================= +// AutoDenyHandler - Automatically denies (for testing) +// ============================================================================= + +// AutoDenyHandler automatically denies all requests. +// Use this for: +// - Testing error handling paths +// - Temporarily disabling operations +// - Safety fallback when approval system is unavailable +class AutoDenyHandler : public ApprovalHandler { + public: + explicit AutoDenyHandler(const std::string& reason = "Auto-denied") + : reason_(reason) {} + + void requestApproval( + const ApprovalRequest& request, + std::function callback) override { + (void)request; + callback(ApprovalResponse::deny(reason_)); + } + + private: + std::string reason_; +}; + +// ============================================================================= +// ConditionalApprovalHandler - Approve based on condition +// ============================================================================= + +// ConditionalApprovalHandler approves or denies based on a predicate. +// Useful for rule-based automatic approval of certain operations. +class ConditionalApprovalHandler : public ApprovalHandler { + public: + using Predicate = std::function; + + explicit ConditionalApprovalHandler( + Predicate predicate, + const std::string& approve_reason = "Condition met", + const std::string& deny_reason = "Condition not met") + : predicate_(std::move(predicate)), + approve_reason_(approve_reason), + deny_reason_(deny_reason) {} + + void requestApproval( + const ApprovalRequest& request, + std::function callback) override { + if (predicate_(request)) { + callback(ApprovalResponse::approve(approve_reason_)); + } else { + callback(ApprovalResponse::deny(deny_reason_)); + } + } + + private: + Predicate predicate_; + std::string approve_reason_; + std::string deny_reason_; +}; + +// ============================================================================= +// RecordingApprovalHandler - Records requests for testing +// ============================================================================= + +// RecordingApprovalHandler records all requests and delegates to an inner +// handler. Useful for testing that the right requests are being made. +class RecordingApprovalHandler : public ApprovalHandler { + public: + explicit RecordingApprovalHandler(std::shared_ptr inner) + : inner_(std::move(inner)) {} + + void requestApproval( + const ApprovalRequest& request, + std::function callback) override { + { + std::lock_guard lock(mutex_); + recorded_requests_.push_back(request); + } + inner_->requestApproval(request, std::move(callback)); + } + + // Get all recorded requests + std::vector recordedRequests() const { + std::lock_guard lock(mutex_); + return recorded_requests_; + } + + // Get the number of recorded requests + size_t requestCount() const { + std::lock_guard lock(mutex_); + return recorded_requests_.size(); + } + + // Clear recorded requests + void clearRecords() { + std::lock_guard lock(mutex_); + recorded_requests_.clear(); + } + + private: + std::shared_ptr inner_; + mutable std::mutex mutex_; + std::vector recorded_requests_; +}; + +// ============================================================================= +// Convenience type aliases +// ============================================================================= + +// JSON-to-JSON human approval wrapper +using JsonHumanApproval = HumanApproval; + +} // namespace human +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/llm/anthropic_provider.h b/third_party/gopher-orch/include/gopher/orch/llm/anthropic_provider.h new file mode 100644 index 00000000..c538bead --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/llm/anthropic_provider.h @@ -0,0 +1,139 @@ +#pragma once + +// AnthropicProvider - Anthropic API implementation of LLMProvider +// +// Supports Anthropic's Messages API including tool use. +// Compatible with Claude models (claude-3-opus, claude-3-sonnet, +// claude-3-haiku, etc.) +// +// Usage: +// auto provider = AnthropicProvider::create("sk-ant-..."); +// +// LLMConfig config("claude-3-5-sonnet-latest"); +// provider->chat(messages, tools, config, dispatcher, callback); + +#include +#include +#include + +#include "gopher/orch/llm/llm_provider.h" + +namespace gopher { +namespace orch { +namespace llm { + +// Forward declaration +class AnthropicProvider; +using AnthropicProviderPtr = std::shared_ptr; + +// Anthropic-specific configuration +struct AnthropicConfig { + std::string api_key; + std::string base_url = "https://api.anthropic.com"; + std::string api_version = "2023-06-01"; + + // Beta features + bool enable_computer_use = false; + std::vector betas; // Beta feature flags + + AnthropicConfig() = default; + explicit AnthropicConfig(const std::string& key) : api_key(key) {} + + AnthropicConfig& withBaseUrl(const std::string& url) { + base_url = url; + return *this; + } + + AnthropicConfig& withApiVersion(const std::string& version) { + api_version = version; + return *this; + } + + AnthropicConfig& withBeta(const std::string& beta) { + betas.push_back(beta); + return *this; + } + + AnthropicConfig& withComputerUse(bool enable = true) { + enable_computer_use = enable; + if (enable) { + betas.push_back("computer-use-2024-10-22"); + } + return *this; + } +}; + +// AnthropicProvider - Anthropic API implementation +// +// Supported models: +// - claude-3-5-sonnet-latest, claude-3-5-sonnet-20241022 +// - claude-3-5-haiku-latest, claude-3-5-haiku-20241022 +// - claude-3-opus-20240229 +// - claude-3-sonnet-20240229 +// - claude-3-haiku-20240307 +// +// Thread Safety: +// - Thread-safe after construction +// - All callbacks invoked in dispatcher context +class AnthropicProvider : public LLMProvider { + public: + using Ptr = std::shared_ptr; + + // Factory methods + static Ptr create(const std::string& api_key); + static Ptr create(const std::string& api_key, const std::string& base_url); + static Ptr create(const AnthropicConfig& config); + + ~AnthropicProvider() override; + + // LLMProvider interface + std::string name() const override { return "anthropic"; } + + void chat(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) override; + + bool supportsStreaming() const override { return true; } + + void chatStream(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + StreamCallback on_chunk, + ChatCallback on_complete) override; + + bool isModelSupported(const std::string& model) const override; + std::vector supportedModels() const override; + + std::string endpoint() const override; + bool isConfigured() const override; + + private: + explicit AnthropicProvider(const AnthropicConfig& config); + + // Build request JSON (Anthropic format) + JsonValue buildRequest(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + bool stream = false) const; + + // Parse response JSON + Result parseResponse(const JsonValue& response) const; + + // Convert Message to Anthropic format + // Note: Anthropic separates system from messages + std::pair messagesToAnthropicFormat( + const std::vector& messages) const; + + // Convert ToolSpec to Anthropic tool format + JsonValue toolToJson(const ToolSpec& tool) const; + + class Impl; + std::unique_ptr impl_; +}; + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/llm/llm.h b/third_party/gopher-orch/include/gopher/orch/llm/llm.h new file mode 100644 index 00000000..10de6dd2 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/llm/llm.h @@ -0,0 +1,55 @@ +#pragma once + +// LLM Module - Unified interface for LLM providers +// +// This module provides: +// - LLMProvider: Abstract interface for LLM API calls +// - OpenAIProvider: OpenAI API (GPT-4, etc.) +// - AnthropicProvider: Anthropic API (Claude models) +// - Common types: Message, ToolCall, LLMResponse, etc. +// +// Usage: +// #include "gopher/orch/llm/llm.h" +// using namespace gopher::orch::llm; +// +// auto provider = createOpenAIProvider("sk-..."); +// LLMConfig config("gpt-4o"); +// config.withTemperature(0.7); +// +// std::vector messages = { +// Message::system("You are a helpful assistant."), +// Message::user("Hello!") +// }; +// +// provider->chat(messages, {}, config, dispatcher, [](Result r) +// { +// if (r.isOk()) { +// std::cout << r.value().message.content << std::endl; +// } +// }); + +// Core types +#include "gopher/orch/llm/llm_types.h" + +// Base provider interface +#include "gopher/orch/llm/llm_provider.h" + +// Provider implementations +#include "gopher/orch/llm/anthropic_provider.h" +#include "gopher/orch/llm/openai_provider.h" + +namespace gopher { +namespace orch { +namespace llm { + +// Convenience re-exports at llm namespace level + +// Types +using core::Dispatcher; +using core::Error; +using core::JsonValue; +using core::Result; + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/llm/llm_provider.h b/third_party/gopher-orch/include/gopher/orch/llm/llm_provider.h new file mode 100644 index 00000000..d322c57a --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/llm/llm_provider.h @@ -0,0 +1,191 @@ +#pragma once + +// LLMProvider - Abstract interface for LLM providers +// +// Provides a unified async interface for interacting with various LLM providers +// (OpenAI, Anthropic, Ollama, etc.). Each provider implements this interface +// to handle provider-specific API details. +// +// Usage: +// auto provider = OpenAIProvider::create(api_key); +// LLMConfig config("gpt-4"); +// config.withTemperature(0.7); +// +// provider->chat(messages, tools, config, dispatcher, [](Result +// r) { +// if (r.isOk()) { +// auto response = r.value(); +// // Handle response... +// } +// }); + +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" +#include "gopher/orch/llm/llm_types.h" + +namespace gopher { +namespace orch { +namespace llm { + +using namespace gopher::orch::core; + +// Forward declarations +class LLMProvider; +using LLMProviderPtr = std::shared_ptr; + +// Callback types +using ChatCallback = std::function)>; +using StreamCallback = std::function; + +// LLMProvider - Abstract base class for LLM providers +// +// Thread Safety: +// - All public methods must be called from dispatcher thread +// - Callbacks are invoked in dispatcher thread context +// +// Implementations: +// - OpenAIProvider: OpenAI API (GPT-4, GPT-3.5, etc.) +// - AnthropicProvider: Anthropic API (Claude models) +// - OllamaProvider: Local Ollama server +class LLMProvider { + public: + using Ptr = std::shared_ptr; + + virtual ~LLMProvider() = default; + + // Provider identification + virtual std::string name() const = 0; + + // ═══════════════════════════════════════════════════════════════════════════ + // CHAT COMPLETION + // ═══════════════════════════════════════════════════════════════════════════ + + // Send a chat completion request + // + // Parameters: + // messages - Conversation history + // tools - Available tools (empty if no tools) + // config - Model configuration (model, temperature, etc.) + // dispatcher - Event dispatcher for async callback + // callback - Called with response or error + // + // The callback receives: + // - LLMResponse on success (may contain tool_calls if LLM wants to use + // tools) + // - Error on failure (network, auth, rate limit, etc.) + virtual void chat(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) = 0; + + // Convenience overload without tools + void chat(const std::vector& messages, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) { + chat(messages, {}, config, dispatcher, std::move(callback)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // STREAMING (Optional) + // ═══════════════════════════════════════════════════════════════════════════ + + // Check if provider supports streaming + virtual bool supportsStreaming() const { return false; } + + // Stream a chat completion request + // + // Parameters: + // messages - Conversation history + // tools - Available tools + // config - Model configuration + // dispatcher - Event dispatcher + // on_chunk - Called for each chunk received + // on_complete - Called when stream completes or errors + // + // Default implementation falls back to non-streaming chat + virtual void chatStream(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + StreamCallback on_chunk, + ChatCallback on_complete) { + // Default: fall back to non-streaming + chat(messages, tools, config, dispatcher, std::move(on_complete)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // VALIDATION + // ═══════════════════════════════════════════════════════════════════════════ + + // Check if a model is supported by this provider + virtual bool isModelSupported(const std::string& model) const = 0; + + // Get list of supported models (may be empty if dynamic) + virtual std::vector supportedModels() const { return {}; } + + // ═══════════════════════════════════════════════════════════════════════════ + // CONFIGURATION + // ═══════════════════════════════════════════════════════════════════════════ + + // Get current API endpoint (for debugging/logging) + virtual std::string endpoint() const = 0; + + // Check if provider is properly configured (has API key, etc.) + virtual bool isConfigured() const = 0; +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// PROVIDER FACTORY +// ═══════════════════════════════════════════════════════════════════════════ + +// Provider types for factory +enum class ProviderType { OPENAI, ANTHROPIC, OLLAMA, CUSTOM }; + +// Provider configuration +struct ProviderConfig { + ProviderType type = ProviderType::OPENAI; + std::string api_key; + std::string base_url; // Override default endpoint + std::map headers; // Additional headers + + ProviderConfig() = default; + explicit ProviderConfig(ProviderType t) : type(t) {} + + ProviderConfig& withApiKey(const std::string& key) { + api_key = key; + return *this; + } + + ProviderConfig& withBaseUrl(const std::string& url) { + base_url = url; + return *this; + } + + ProviderConfig& withHeader(const std::string& name, + const std::string& value) { + headers[name] = value; + return *this; + } +}; + +// Factory function to create providers +// Implemented in llm_factory.cpp +LLMProviderPtr createProvider(const ProviderConfig& config); + +// Convenience factory functions +LLMProviderPtr createOpenAIProvider(const std::string& api_key, + const std::string& base_url = ""); +LLMProviderPtr createAnthropicProvider(const std::string& api_key, + const std::string& base_url = ""); +LLMProviderPtr createOllamaProvider( + const std::string& base_url = "http://localhost:11434"); + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/llm/llm_runnable.h b/third_party/gopher-orch/include/gopher/orch/llm/llm_runnable.h new file mode 100644 index 00000000..b7e8e82a --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/llm/llm_runnable.h @@ -0,0 +1,120 @@ +#pragma once + +// LLMRunnable - Wraps LLMProvider as a composable Runnable +// +// Enables LLM calls to be composed with other Runnables in pipelines, +// sequences, and graphs. Transforms JSON input into LLM chat requests +// and returns LLM responses as JSON. +// +// Usage: +// auto provider = createOpenAIProvider("sk-..."); +// auto llm = LLMRunnable::create(provider, LLMConfig("gpt-4")); +// +// JsonValue input = JsonValue::object(); +// input["messages"] = messages_array; +// +// llm->invoke(input, config, dispatcher, [](Result result) { +// // Handle result... +// }); + +#include +#include + +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/llm/llm_provider.h" +#include "gopher/orch/llm/llm_types.h" + +namespace gopher { +namespace orch { +namespace llm { + +using namespace gopher::orch::core; + +// LLMRunnable - Adapter that makes LLMProvider a Runnable +// +// Input Schema: +// { +// "messages": [ +// {"role": "system", "content": "..."}, +// {"role": "user", "content": "..."} +// ], +// "tools": [...], // optional +// "config": {...} // optional, overrides default config +// } +// +// Alternative: Simple string input becomes a user message +// "Hello, how are you?" +// +// Output Schema: +// { +// "message": { +// "role": "assistant", +// "content": "...", +// "tool_calls": [...] // optional +// }, +// "finish_reason": "stop" | "tool_calls" | "length", +// "usage": { +// "prompt_tokens": 50, +// "completion_tokens": 20, +// "total_tokens": 70 +// } +// } +class LLMRunnable : public Runnable { + public: + using Ptr = std::shared_ptr; + + // Factory method + static Ptr create(LLMProviderPtr provider, + const LLMConfig& config = LLMConfig()); + + // Runnable interface + std::string name() const override; + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override; + + // Accessors + LLMProviderPtr provider() const { return provider_; } + const LLMConfig& defaultConfig() const { return default_config_; } + + // Set default config + void setDefaultConfig(const LLMConfig& config) { default_config_ = config; } + + private: + LLMRunnable(LLMProviderPtr provider, const LLMConfig& config); + + // Parse input JSON into messages, tools, and config + struct ParsedInput { + std::vector messages; + std::vector tools; + LLMConfig config; + }; + ParsedInput parseInput(const JsonValue& input) const; + + // Convert LLMResponse to JSON output + static JsonValue responseToJson(const LLMResponse& response); + + // Convert Message to JSON + static JsonValue messageToJson(const Message& message); + + // Parse Message from JSON + static Message parseMessage(const JsonValue& json); + + // Parse ToolSpec from JSON + static ToolSpec parseToolSpec(const JsonValue& json); + + LLMProviderPtr provider_; + LLMConfig default_config_; +}; + +// Convenience factory function +inline LLMRunnable::Ptr makeLLMRunnable(LLMProviderPtr provider, + const LLMConfig& config = LLMConfig()) { + return LLMRunnable::create(std::move(provider), config); +} + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/llm/llm_types.h b/third_party/gopher-orch/include/gopher/orch/llm/llm_types.h new file mode 100644 index 00000000..535eec6b --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/llm/llm_types.h @@ -0,0 +1,279 @@ +#pragma once + +// LLM Types - Core types for LLM provider integration +// +// Provides message types, tool call structures, and response types +// for interacting with LLM providers (OpenAI, Anthropic, Ollama, etc.) + +#include +#include +#include + +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +namespace llm { + +using namespace gopher::orch::core; + +// ═══════════════════════════════════════════════════════════════════════════ +// MESSAGE TYPES +// ═══════════════════════════════════════════════════════════════════════════ + +// Forward declaration +struct ToolCall; + +// Message role in conversation +enum class Role { + SYSTEM, // System prompt + USER, // User message + ASSISTANT, // Assistant response + TOOL // Tool result +}; + +// Convert Role to string +inline std::string roleToString(Role role) { + switch (role) { + case Role::SYSTEM: + return "system"; + case Role::USER: + return "user"; + case Role::ASSISTANT: + return "assistant"; + case Role::TOOL: + return "tool"; + default: + return "user"; + } +} + +// Parse string to Role +inline Role parseRole(const std::string& role) { + if (role == "system") + return Role::SYSTEM; + if (role == "user") + return Role::USER; + if (role == "assistant") + return Role::ASSISTANT; + if (role == "tool") + return Role::TOOL; + return Role::USER; +} + +// Tool call requested by LLM +struct ToolCall { + std::string id; // Unique ID for this call (used for matching results) + std::string name; // Tool name to call + JsonValue arguments; // Arguments as JSON + + ToolCall() = default; + ToolCall(const std::string& id_, + const std::string& name_, + const JsonValue& args_) + : id(id_), name(name_), arguments(args_) {} +}; + +// Message in conversation +struct Message { + Role role; + std::string content; + + // For tool responses (role = TOOL) + optional tool_call_id; + + // For assistant messages with tool calls + optional> tool_calls; + + Message() : role(Role::USER) {} + + Message(Role r, const std::string& c) + : role(r), content(c), tool_call_id(nullopt), tool_calls(nullopt) {} + + // Factory methods for convenience + static Message system(const std::string& content) { + return Message(Role::SYSTEM, content); + } + + static Message user(const std::string& content) { + return Message(Role::USER, content); + } + + static Message assistant(const std::string& content) { + Message msg(Role::ASSISTANT, content); + return msg; + } + + static Message assistantWithToolCalls(const std::vector& calls) { + Message msg(Role::ASSISTANT, ""); + msg.tool_calls = calls; + return msg; + } + + static Message toolResult(const std::string& call_id, + const std::string& content) { + Message msg(Role::TOOL, content); + msg.tool_call_id = call_id; + return msg; + } + + // Check if message has tool calls + bool hasToolCalls() const { + return tool_calls.has_value() && !tool_calls->empty(); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// TOOL SPECIFICATION (For telling LLM what tools are available) +// ═══════════════════════════════════════════════════════════════════════════ + +struct ToolSpec { + std::string name; + std::string description; + JsonValue parameters; // JSON Schema for parameters + + ToolSpec() = default; + ToolSpec(const std::string& n, const std::string& d, const JsonValue& p) + : name(n), description(d), parameters(p) {} +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// LLM CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════ + +struct LLMConfig { + std::string model; // e.g., "gpt-4", "claude-3-opus-20240229" + + optional temperature; // 0.0 - 2.0 + optional max_tokens; // Max response tokens + optional top_p; // Nucleus sampling + optional seed; // For reproducibility + + optional> stop; // Stop sequences + + // Request timeout + std::chrono::milliseconds timeout{60000}; + + LLMConfig() = default; + explicit LLMConfig(const std::string& m) : model(m) {} + + // Builder pattern + LLMConfig& withModel(const std::string& m) { + model = m; + return *this; + } + + LLMConfig& withTemperature(double t) { + temperature = t; + return *this; + } + + LLMConfig& withMaxTokens(int t) { + max_tokens = t; + return *this; + } + + LLMConfig& withTopP(double p) { + top_p = p; + return *this; + } + + LLMConfig& withSeed(int s) { + seed = s; + return *this; + } + + LLMConfig& withStop(const std::vector& s) { + stop = s; + return *this; + } + + LLMConfig& withTimeout(std::chrono::milliseconds t) { + timeout = t; + return *this; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// USAGE STATISTICS +// ═══════════════════════════════════════════════════════════════════════════ + +struct Usage { + int prompt_tokens = 0; + int completion_tokens = 0; + int total_tokens = 0; + + Usage() = default; + Usage(int prompt, int completion) + : prompt_tokens(prompt), + completion_tokens(completion), + total_tokens(prompt + completion) {} +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// LLM RESPONSE +// ═══════════════════════════════════════════════════════════════════════════ + +struct LLMResponse { + Message message; // The response message + std::string + finish_reason; // "stop", "tool_calls", "length", "content_filter" + optional usage; + + LLMResponse() = default; + + // Check if LLM wants to call tools + bool hasToolCalls() const { return message.hasToolCalls(); } + + // Get tool calls (empty vector if none) + const std::vector& toolCalls() const { + static const std::vector empty; + return message.tool_calls.has_value() ? *message.tool_calls : empty; + } + + // Check if conversation is complete (no more tool calls needed) + bool isComplete() const { + return finish_reason == "stop" || finish_reason == "end_turn"; + } + + // Check if response was truncated due to token limit + bool isTruncated() const { return finish_reason == "length"; } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// STREAMING TYPES (Optional, for streaming support) +// ═══════════════════════════════════════════════════════════════════════════ + +struct StreamDelta { + optional content; // Content chunk + optional tool_call; // Tool call chunk (partial) + optional finish_reason; +}; + +struct StreamChunk { + StreamDelta delta; + bool is_final = false; +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// ERROR CODES +// ═══════════════════════════════════════════════════════════════════════════ + +namespace LLMError { +enum : int { + OK = 0, + INVALID_API_KEY = -100, + RATE_LIMITED = -101, + CONTEXT_LENGTH_EXCEEDED = -102, + INVALID_MODEL = -103, + CONTENT_FILTERED = -104, + SERVICE_UNAVAILABLE = -105, + NETWORK_ERROR = -106, + PARSE_ERROR = -107, + UNKNOWN = -199 +}; +} // namespace LLMError + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/llm/openai_provider.h b/third_party/gopher-orch/include/gopher/orch/llm/openai_provider.h new file mode 100644 index 00000000..70ef3449 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/llm/openai_provider.h @@ -0,0 +1,143 @@ +#pragma once + +// OpenAIProvider - OpenAI API implementation of LLMProvider +// +// Supports OpenAI's chat completion API including function/tool calling. +// Compatible with OpenAI API and OpenAI-compatible endpoints (Azure, etc.) +// +// Usage: +// auto provider = OpenAIProvider::create("sk-..."); +// // Or with custom endpoint: +// auto provider = OpenAIProvider::create("sk-...", +// "https://custom.endpoint.com/v1"); +// +// LLMConfig config("gpt-4"); +// provider->chat(messages, tools, config, dispatcher, callback); + +#include +#include +#include + +#include "gopher/orch/llm/llm_provider.h" + +namespace gopher { +namespace orch { +namespace llm { + +// Forward declaration +class OpenAIProvider; +using OpenAIProviderPtr = std::shared_ptr; + +// OpenAI-specific configuration +struct OpenAIConfig { + std::string api_key; + std::string base_url = "https://api.openai.com/v1"; + std::string organization; // Optional org ID + + // Azure OpenAI specific + bool is_azure = false; + std::string azure_api_version = "2024-02-15-preview"; + std::string azure_deployment; // Deployment name for Azure + + OpenAIConfig() = default; + explicit OpenAIConfig(const std::string& key) : api_key(key) {} + + OpenAIConfig& withBaseUrl(const std::string& url) { + base_url = url; + return *this; + } + + OpenAIConfig& withOrganization(const std::string& org) { + organization = org; + return *this; + } + + OpenAIConfig& forAzure( + const std::string& deployment, + const std::string& api_version = "2024-02-15-preview") { + is_azure = true; + azure_deployment = deployment; + azure_api_version = api_version; + return *this; + } +}; + +// OpenAIProvider - OpenAI API implementation +// +// Supported models: +// - gpt-4, gpt-4-turbo, gpt-4o, gpt-4o-mini +// - gpt-3.5-turbo +// - o1, o1-mini, o1-preview (reasoning models) +// +// Thread Safety: +// - Thread-safe after construction +// - All callbacks invoked in dispatcher context +class OpenAIProvider : public LLMProvider { + public: + using Ptr = std::shared_ptr; + + // Factory methods + static Ptr create(const std::string& api_key); + static Ptr create(const std::string& api_key, const std::string& base_url); + static Ptr create(const OpenAIConfig& config); + + ~OpenAIProvider() override; + + // LLMProvider interface + std::string name() const override { return "openai"; } + + void chat(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) override; + + bool supportsStreaming() const override { return true; } + + void chatStream(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + StreamCallback on_chunk, + ChatCallback on_complete) override; + + bool isModelSupported(const std::string& model) const override; + std::vector supportedModels() const override; + + std::string endpoint() const override; + bool isConfigured() const override; + + // OpenAI-specific methods + + // Get/set organization ID + std::string organization() const; + void setOrganization(const std::string& org); + + private: + explicit OpenAIProvider(const OpenAIConfig& config); + + // Build request JSON + JsonValue buildRequest(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + bool stream = false) const; + + // Parse response JSON + Result parseResponse(const JsonValue& response) const; + + // Parse streaming chunk + Result parseStreamChunk(const std::string& data) const; + + // Convert Message to OpenAI format + JsonValue messageToJson(const Message& msg) const; + + // Convert ToolSpec to OpenAI function format + JsonValue toolToJson(const ToolSpec& tool) const; + + class Impl; + std::unique_ptr impl_; +}; + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/orch.h b/third_party/gopher-orch/include/gopher/orch/orch.h new file mode 100644 index 00000000..45deb66d --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/orch.h @@ -0,0 +1,263 @@ +#pragma once + +// gopher-orch - MCP Server Orchestration Framework +// +// Provides composable building blocks for agentic workflows: +// - Runnable: Universal async operation interface +// - Sequence, Parallel, Router: Composition patterns +// - StateGraph: Stateful workflow graphs (Pregel model) +// - StateMachine: Entity lifecycle management (FSM) +// - Server: Protocol-agnostic server abstraction +// - Resilience: Retry, Timeout, Fallback, CircuitBreaker +// +// Design principles: +// - Async-first with dispatcher-based callbacks +// - Type-safe with C++14 compatibility +// - Protocol-agnostic (MCP, REST, mock) +// - Explicit - no hidden magic + +// Core types and utilities +#include "gopher/orch/core/config.h" +#include "gopher/orch/core/lambda.h" +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/core/types.h" + +// Composition patterns +#include "gopher/orch/composition/parallel.h" +#include "gopher/orch/composition/router.h" +#include "gopher/orch/composition/sequence.h" + +// Resilience patterns +#include "gopher/orch/resilience/circuit_breaker.h" +#include "gopher/orch/resilience/fallback.h" +#include "gopher/orch/resilience/retry.h" +#include "gopher/orch/resilience/timeout.h" + +// Graph patterns +#include "gopher/orch/graph/state_graph.h" + +// Finite State Machine +#include "gopher/orch/fsm/state_machine.h" + +// Callback system (Observability) +#include "gopher/orch/callback/callback_handler.h" +#include "gopher/orch/callback/callback_manager.h" + +// Human-in-the-Loop +#include "gopher/orch/human/approval.h" + +// LLM Providers +#include "gopher/orch/llm/llm.h" + +// Agent Framework +#include "gopher/orch/agent/agent_module.h" + +// Server abstraction +#include "gopher/orch/server/mock_server.h" +#include "gopher/orch/server/server.h" +#include "gopher/orch/server/server_composite.h" + +// MCP Server and REST Server (require gopher-mcp dependency) +// Conditionally included to avoid hard dependency +#ifdef GOPHER_ORCH_WITH_MCP +#include "gopher/orch/server/mcp_server.h" +#include "gopher/orch/server/rest_server.h" +#endif + +// FFI Layer - C API for cross-language bindings +// The C API headers are always available. The bridge header is internal. +// Use GOPHER_ORCH_WITH_FFI to include RAII C++ wrapper utilities. +#include "gopher/orch/ffi/orch_ffi.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#ifdef GOPHER_ORCH_WITH_FFI +#include "gopher/orch/ffi/orch_ffi_raii.h" +#endif + +// Convenience namespace imports +namespace gopher { +namespace orch { + +// Re-export core types at orch level +using core::Dispatcher; +using core::Error; +using core::JsonCallback; +using core::JsonRunnable; +using core::JsonRunnablePtr; +using core::JsonValue; +using core::Lambda; +using core::makeJsonLambda; +using core::makeLambda; +using core::makeLambdaAsync; +using core::makeOrchError; +using core::makeSuccess; +using core::nullopt; +using core::optional; +namespace OrchError = core::OrchError; // Namespace alias +using core::Result; +using core::ResultCallback; +using core::Runnable; +using core::RunnableConfig; + +// Re-export composition patterns +using composition::Parallel; +using composition::parallel; +using composition::ParallelBuilder; +using composition::Router; +using composition::router; +using composition::RouterBuilder; +using composition::Sequence; +using composition::sequence; +using composition::Sequence2; +using composition::SequenceBuilder; + +// Re-export resilience patterns +using resilience::CircuitBreaker; +using resilience::CircuitBreakerPolicy; +using resilience::CircuitState; +using resilience::Fallback; +using resilience::FallbackBuilder; +using resilience::JsonCircuitBreaker; +using resilience::JsonFallback; +using resilience::JsonRetry; +using resilience::JsonTimeout; +using resilience::Retry; +using resilience::RetryPolicy; +using resilience::Timeout; +using resilience::withCircuitBreaker; +using resilience::withFallback; +using resilience::withRetry; +using resilience::withTimeout; + +// Re-export graph patterns +using graph::ChannelConfig; +using graph::CompiledStateGraph; +using graph::GraphNode; +using graph::GraphState; +using graph::GraphStateCallback; +using graph::StateChannel; +using graph::StateGraph; +namespace reducers = graph::reducers; // Namespace alias for reducers + +// Re-export FSM components +using fsm::makeStateMachine; +using fsm::StateMachine; +using fsm::StateMachineBuilder; + +// Re-export callback system components +using callback::CallbackHandler; +using callback::CallbackManager; +using callback::ChainGuard; +using callback::EventType; +using callback::LoggingCallbackHandler; +using callback::NoOpCallbackHandler; +using callback::RunInfo; +using callback::ToolGuard; + +// Re-export human-in-the-loop components +using human::ApprovalHandler; +using human::ApprovalRequest; +using human::ApprovalResponse; +using human::AsyncCallbackApprovalHandler; +using human::AutoApprovalHandler; +using human::AutoDenyHandler; +using human::CallbackApprovalHandler; +using human::ConditionalApprovalHandler; +using human::HumanApproval; +using human::JsonHumanApproval; +using human::RecordingApprovalHandler; + +// Re-export server components +using server::ConnectionCallback; +using server::ConnectionState; +using server::makeMockServer; +using server::MockServer; +using server::Server; +using server::ServerComposite; +using server::ServerCompositePtr; +using server::ServerPtr; +using server::ServerTool; +using server::ServerToolInfo; +using server::ServerToolListCallback; +using server::ServerToolPtr; +using server::ToolMapping; + +// MCP Server and REST Server exports (conditional) +#ifdef GOPHER_ORCH_WITH_MCP +using server::HttpClient; +using server::HttpMethod; +using server::HttpResponse; +using server::makeRESTServer; +using server::MCPServer; +using server::MCPServerConfig; +using server::MCPServerPtr; +using server::RESTServer; +using server::RESTServerConfig; +using server::RESTServerPtr; +#endif + +// Re-export LLM components +using llm::AnthropicConfig; +using llm::AnthropicProvider; +using llm::ChatCallback; +using llm::createAnthropicProvider; +using llm::createOpenAIProvider; +using llm::createProvider; +using llm::LLMConfig; +using llm::LLMProvider; +using llm::LLMProviderPtr; +using llm::LLMResponse; +using llm::Message; +using llm::OpenAIConfig; +using llm::OpenAIProvider; +using llm::ProviderConfig; +using llm::ProviderType; +using llm::Role; +using llm::StreamCallback; +using llm::StreamChunk; +using llm::StreamDelta; +using llm::ToolCall; +using llm::ToolSpec; +using llm::Usage; +namespace LLMError = llm::LLMError; // Namespace alias for error codes + +// Re-export Agent components +using agent::Agent; +using agent::AgentCallback; +using agent::AgentConfig; +using agent::AgentPtr; +using agent::AgentResult; +using agent::AgentState; +using agent::AgentStatus; +using agent::AgentStep; +using agent::makeAgent; +using agent::makeToolRegistry; +using agent::ReActAgent; +using agent::StepCallback; +using agent::ToolApprovalCallback; +using agent::ToolEntry; +using agent::ToolExecution; +using agent::ToolFunction; +using agent::ToolRegistry; +using agent::ToolRegistryPtr; +using agent::toServerToolInfo; // Convert ToolSpec -> ServerToolInfo +using agent::toToolSpec; // Convert ServerToolInfo -> ToolSpec +namespace AgentError = agent::AgentError; // Namespace alias for error codes + +// Re-export Tool Definition and Config types +using agent::AuthPreset; +using agent::ConfigLoader; +using agent::makeRESTToolAdapter; +using agent::MCPServerDefinition; +using agent::RegistryConfig; +using agent::RESTToolAdapter; +using agent::RESTToolAdapterPtr; +using agent::ToolDefinition; + +// FFI C++ utilities (conditional) +// The C API (gopher_orch_*) is always available in the global namespace +#ifdef GOPHER_ORCH_WITH_FFI +namespace ffi_utils = ffi; // Alias for FFI RAII utilities +#endif + +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/resilience/circuit_breaker.h b/third_party/gopher-orch/include/gopher/orch/resilience/circuit_breaker.h new file mode 100644 index 00000000..9b9784b5 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/resilience/circuit_breaker.h @@ -0,0 +1,250 @@ +#pragma once + +// CircuitBreaker - Prevent cascade failures +// Implements the circuit breaker pattern to stop calling failing services +// +// States: +// - CLOSED: Normal operation, requests pass through +// - OPEN: Failures exceeded threshold, requests immediately rejected +// - HALF_OPEN: Testing if service recovered, limited requests allowed +// +// Behavior: +// - Tracks failures and opens circuit when threshold reached +// - Rejects requests immediately when open (fail-fast) +// - Tries limited requests after recovery timeout (half-open) +// - Closes circuit when half-open requests succeed + +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace resilience { + +using namespace gopher::orch::core; + +// CircuitBreaker states +enum class CircuitState { CLOSED, OPEN, HALF_OPEN }; + +// CircuitBreakerPolicy - Configuration for circuit breaker behavior +struct CircuitBreakerPolicy { + uint32_t failure_threshold; // Number of failures before opening + uint64_t recovery_timeout_ms; // Time to wait before trying half-open + uint32_t half_open_max_calls; // Number of successful calls to close + + // Optional: callback for state changes (for logging/observability) + std::function on_state_change; + + CircuitBreakerPolicy() + : failure_threshold(5), + recovery_timeout_ms(30000), + half_open_max_calls(3), + on_state_change(nullptr) {} + + // Factory for common configurations + static CircuitBreakerPolicy standard() { return CircuitBreakerPolicy(); } + + static CircuitBreakerPolicy aggressive(uint32_t failure_threshold = 3, + uint64_t recovery_timeout_ms = 10000) { + CircuitBreakerPolicy policy; + policy.failure_threshold = failure_threshold; + policy.recovery_timeout_ms = recovery_timeout_ms; + return policy; + } + + static CircuitBreakerPolicy lenient(uint32_t failure_threshold = 10, + uint64_t recovery_timeout_ms = 60000) { + CircuitBreakerPolicy policy; + policy.failure_threshold = failure_threshold; + policy.recovery_timeout_ms = recovery_timeout_ms; + return policy; + } +}; + +// CircuitBreaker - Prevent cascade failures +template +class CircuitBreaker : public Runnable { + public: + using RunnablePtr = std::shared_ptr>; + using Callback = typename Runnable::Callback; + + CircuitBreaker(RunnablePtr inner, CircuitBreakerPolicy policy) + : inner_(std::move(inner)), + policy_(std::move(policy)), + state_(CircuitState::CLOSED), + failure_count_(0), + half_open_successes_(0), + last_failure_time_(0) {} + + std::string name() const override { + return "CircuitBreaker(" + inner_->name() + ")"; + } + + // Get current circuit state + CircuitState state() const { return state_.load(); } + + // Get failure count + uint32_t failureCount() const { return failure_count_.load(); } + + void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Check and potentially transition state + CircuitState current_state = checkAndTransitionState(); + + if (current_state == CircuitState::OPEN) { + // Circuit is open - fail fast + dispatcher.post([callback = std::move(callback)]() { + callback( + makeOrchError(OrchError::CIRCUIT_OPEN, "Circuit is open")); + }); + return; + } + + // Circuit is closed or half-open - try the operation + auto self = std::static_pointer_cast>( + this->shared_from_this()); + + inner_->invoke( + input, config.child(), dispatcher, + [self, callback = std::move(callback)](Result result) mutable { + if (mcp::holds_alternative(result)) { + self->onSuccess(); + } else { + self->onFailure(); + } + callback(std::move(result)); + }); + } + + // Manual reset of circuit breaker (for testing/admin purposes) + void reset() { + std::lock_guard lock(mutex_); + transitionTo(CircuitState::CLOSED); + failure_count_.store(0); + half_open_successes_.store(0); + } + + // Factory method + static std::shared_ptr> create( + RunnablePtr inner, CircuitBreakerPolicy policy = CircuitBreakerPolicy()) { + return std::make_shared>(std::move(inner), + std::move(policy)); + } + + private: + // Check current state and transition if needed (e.g., OPEN -> HALF_OPEN) + CircuitState checkAndTransitionState() { + CircuitState current = state_.load(); + + if (current == CircuitState::OPEN) { + // Check if recovery timeout has elapsed + uint64_t now = currentTimeMs(); + uint64_t last_failure = last_failure_time_.load(); + uint64_t elapsed = now - last_failure; + + if (elapsed >= policy_.recovery_timeout_ms) { + // Try to transition to HALF_OPEN + std::lock_guard lock(mutex_); + if (state_.load() == CircuitState::OPEN) { + transitionTo(CircuitState::HALF_OPEN); + half_open_successes_.store(0); + return CircuitState::HALF_OPEN; + } + } + } + + return state_.load(); + } + + // Called when operation succeeds + void onSuccess() { + std::lock_guard lock(mutex_); + + CircuitState current = state_.load(); + if (current == CircuitState::HALF_OPEN) { + // Count successful calls in half-open state + uint32_t successes = ++half_open_successes_; + if (successes >= policy_.half_open_max_calls) { + // Enough successes - close the circuit + transitionTo(CircuitState::CLOSED); + failure_count_.store(0); + } + } else { + // Reset failure count on success + failure_count_.store(0); + } + } + + // Called when operation fails + void onFailure() { + std::lock_guard lock(mutex_); + + CircuitState current = state_.load(); + if (current == CircuitState::HALF_OPEN) { + // Failure in half-open - immediately reopen + transitionTo(CircuitState::OPEN); + last_failure_time_.store(currentTimeMs()); + } else { + // Count failure and potentially open circuit + uint32_t failures = ++failure_count_; + if (failures >= policy_.failure_threshold) { + transitionTo(CircuitState::OPEN); + last_failure_time_.store(currentTimeMs()); + } + } + } + + // Transition to new state with optional callback + void transitionTo(CircuitState new_state) { + CircuitState old_state = state_.exchange(new_state); + if (old_state != new_state && policy_.on_state_change) { + policy_.on_state_change(old_state, new_state); + } + } + + // Get current time in milliseconds + static uint64_t currentTimeMs() { + auto now = std::chrono::steady_clock::now(); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()); + return static_cast(ms.count()); + } + + RunnablePtr inner_; + CircuitBreakerPolicy policy_; + std::atomic state_; + std::atomic failure_count_; + std::atomic half_open_successes_; + std::atomic last_failure_time_; + std::mutex mutex_; +}; + +// Convenience alias for JSON circuit breaker +using JsonCircuitBreaker = CircuitBreaker; + +// Factory function for creating circuit breaker wrapper +template +std::shared_ptr> withCircuitBreaker( + std::shared_ptr> inner, + CircuitBreakerPolicy policy = CircuitBreakerPolicy()) { + return CircuitBreaker::create(std::move(inner), std::move(policy)); +} + +// Factory for JSON circuit breaker +inline std::shared_ptr withCircuitBreaker( + JsonRunnablePtr inner, + CircuitBreakerPolicy policy = CircuitBreakerPolicy()) { + return JsonCircuitBreaker::create(std::move(inner), std::move(policy)); +} + +} // namespace resilience +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/resilience/fallback.h b/third_party/gopher-orch/include/gopher/orch/resilience/fallback.h new file mode 100644 index 00000000..301587f9 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/resilience/fallback.h @@ -0,0 +1,155 @@ +#pragma once + +// Fallback - Try alternatives on failure +// Chains multiple runnables and tries each in order until one succeeds +// +// Behavior: +// - Tries primary runnable first +// - On failure, tries each fallback in order +// - Returns first successful result +// - Returns FALLBACK_EXHAUSTED error if all fail + +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace resilience { + +using namespace gopher::orch::core; + +// Fallback - Try alternatives on failure +template +class Fallback : public Runnable { + public: + using RunnablePtr = std::shared_ptr>; + using Callback = typename Runnable::Callback; + + Fallback(RunnablePtr primary, std::vector fallbacks) + : primary_(std::move(primary)), fallbacks_(std::move(fallbacks)) {} + + std::string name() const override { + std::string result = "Fallback(" + primary_->name(); + for (const auto& fb : fallbacks_) { + result += " -> " + fb->name(); + } + result += ")"; + return result; + } + + void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Start with primary (index 0) + attemptInvoke(input, config, dispatcher, std::move(callback), 0); + } + + // Get the primary runnable + RunnablePtr primary() const { return primary_; } + + // Get fallback runnables + const std::vector& fallbacks() const { return fallbacks_; } + + // Factory method + static std::shared_ptr> create( + RunnablePtr primary, std::vector fallbacks) { + return std::make_shared>(std::move(primary), + std::move(fallbacks)); + } + + private: + void attemptInvoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback, + size_t index) { + // Get current runnable to try + RunnablePtr current; + if (index == 0) { + current = primary_; + } else if (index <= fallbacks_.size()) { + current = fallbacks_[index - 1]; + } else { + // All fallbacks exhausted + dispatcher.post([callback = std::move(callback)]() { + callback(makeOrchError(OrchError::FALLBACK_EXHAUSTED, + "All fallback options failed")); + }); + return; + } + + auto self = std::static_pointer_cast>( + this->shared_from_this()); + auto input_copy = input; // Copy for potential fallback + + current->invoke( + input, config.child(), dispatcher, + [self, input_copy, config, &dispatcher, callback = std::move(callback), + index](Result result) mutable { + if (mcp::holds_alternative(result)) { + // Success - return result + callback(std::move(result)); + return; + } + + // Failure - try next fallback + self->attemptInvoke(input_copy, config, dispatcher, + std::move(callback), index + 1); + }); + } + + RunnablePtr primary_; + std::vector fallbacks_; +}; + +// Convenience alias for JSON fallback +using JsonFallback = Fallback; + +// Builder for creating Fallback with fluent API +template +class FallbackBuilder { + public: + using RunnablePtr = std::shared_ptr>; + + explicit FallbackBuilder(RunnablePtr primary) + : primary_(std::move(primary)) {} + + // Add a fallback option + FallbackBuilder& orElse(RunnablePtr fallback) { + fallbacks_.push_back(std::move(fallback)); + return *this; + } + + std::shared_ptr> build() { + return Fallback::create(std::move(primary_), + std::move(fallbacks_)); + } + + // Implicit conversion to shared_ptr + operator std::shared_ptr>() { return build(); } + + private: + RunnablePtr primary_; + std::vector fallbacks_; +}; + +// Factory function for creating fallback builder +template +FallbackBuilder withFallback(std::shared_ptr> primary) { + return FallbackBuilder(std::move(primary)); +} + +// Factory for JSON fallback builder +inline FallbackBuilder withFallback( + JsonRunnablePtr primary) { + return FallbackBuilder(std::move(primary)); +} + +} // namespace resilience +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/resilience/retry.h b/third_party/gopher-orch/include/gopher/orch/resilience/retry.h new file mode 100644 index 00000000..919333d6 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/resilience/retry.h @@ -0,0 +1,208 @@ +#pragma once + +// Retry - Wrap a runnable with retry logic +// Implements exponential backoff with optional jitter +// +// Behavior: +// - Retries failed operations up to max_attempts times +// - Delays between retries using exponential backoff +// - Optional jitter to prevent thundering herd +// - Optional retry condition to filter retryable errors + +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace resilience { + +using namespace gopher::orch::core; + +// RetryPolicy - Configuration for retry behavior +struct RetryPolicy { + uint32_t max_attempts; // Maximum number of attempts (including first) + uint64_t initial_delay_ms; // Initial delay before first retry + double backoff_multiplier; // Multiplier for each subsequent retry + uint64_t max_delay_ms; // Maximum delay between retries + bool jitter; // Add random jitter to delays + + // Optional: condition to check if error is retryable + std::function retry_on; + + // Optional: callback on retry (for logging/observability) + std::function on_retry; + + RetryPolicy() + : max_attempts(3), + initial_delay_ms(500), + backoff_multiplier(2.0), + max_delay_ms(30000), + jitter(true), + retry_on(nullptr), + on_retry(nullptr) {} + + // Factory for exponential backoff policy + static RetryPolicy exponential(uint32_t attempts = 3, + uint64_t initial_delay_ms = 500) { + RetryPolicy policy; + policy.max_attempts = attempts; + policy.initial_delay_ms = initial_delay_ms; + return policy; + } + + // Factory for fixed delay policy (no backoff) + static RetryPolicy fixed(uint32_t attempts, uint64_t delay_ms) { + RetryPolicy policy; + policy.max_attempts = attempts; + policy.initial_delay_ms = delay_ms; + policy.backoff_multiplier = 1.0; + policy.jitter = false; + return policy; + } +}; + +// Retry - Wrap a runnable with retry logic +template +class Retry : public Runnable { + public: + using RunnablePtr = std::shared_ptr>; + using Callback = typename Runnable::Callback; + + Retry(RunnablePtr inner, RetryPolicy policy) + : inner_(std::move(inner)), policy_(std::move(policy)) {} + + std::string name() const override { + return "Retry(" + inner_->name() + ", " + + std::to_string(policy_.max_attempts) + ")"; + } + + void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Start first attempt + attemptInvoke(input, config, dispatcher, std::move(callback), 1); + } + + // Factory method + static std::shared_ptr> create(RunnablePtr inner, + RetryPolicy policy) { + return std::make_shared>(std::move(inner), + std::move(policy)); + } + + private: + // State to hold timer during retry delay + // This ensures timer is kept alive until it fires + struct RetryState { + mcp::event::TimerPtr timer; + }; + + void attemptInvoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback, + uint32_t attempt) { + auto self = std::static_pointer_cast>( + this->shared_from_this()); + auto input_copy = input; // Copy for potential retry + + inner_->invoke( + input, config.child(), dispatcher, + [self, input_copy, config, &dispatcher, callback = std::move(callback), + attempt](Result result) mutable { + if (mcp::holds_alternative(result)) { + // Success - return result + callback(std::move(result)); + return; + } + + // Get error for retry decision + const auto& error = mcp::get(result); + + // Check if we should retry + bool should_retry = attempt < self->policy_.max_attempts; + + // Check optional retry condition + if (should_retry && self->policy_.retry_on) { + should_retry = self->policy_.retry_on(error); + } + + if (!should_retry) { + // No more retries - return error + callback(std::move(result)); + return; + } + + // Invoke optional retry callback + if (self->policy_.on_retry) { + self->policy_.on_retry(error, attempt); + } + + // Calculate delay with exponential backoff + uint64_t delay_ms = self->calculateDelay(attempt); + + // Create state to hold timer (keeps timer alive until callback fires) + auto state = std::make_shared(); + + // Schedule retry after delay using timer + state->timer = dispatcher.createTimer( + [self, input_copy, config, &dispatcher, + callback = std::move(callback), attempt, state]() mutable { + // State is captured to keep timer alive until this point + self->attemptInvoke(input_copy, config, dispatcher, + std::move(callback), attempt + 1); + }); + state->timer->enableTimer(std::chrono::milliseconds(delay_ms)); + }); + } + + uint64_t calculateDelay(uint32_t attempt) const { + // Calculate base delay with exponential backoff + double delay = policy_.initial_delay_ms * + std::pow(policy_.backoff_multiplier, attempt - 1); + + // Cap at max delay + if (delay > static_cast(policy_.max_delay_ms)) { + delay = static_cast(policy_.max_delay_ms); + } + + // Add jitter if enabled (±50%) + if (policy_.jitter) { + static thread_local std::mt19937 gen(std::random_device{}()); + std::uniform_real_distribution<> dis(0.5, 1.5); + delay *= dis(gen); + } + + return static_cast(delay); + } + + RunnablePtr inner_; + RetryPolicy policy_; +}; + +// Convenience alias for JSON retry +using JsonRetry = Retry; + +// Factory function for creating retry wrapper +template +std::shared_ptr> withRetry(std::shared_ptr> inner, + RetryPolicy policy = RetryPolicy()) { + return Retry::create(std::move(inner), std::move(policy)); +} + +// Factory for JSON retry +inline std::shared_ptr withRetry( + JsonRunnablePtr inner, RetryPolicy policy = RetryPolicy()) { + return JsonRetry::create(std::move(inner), std::move(policy)); +} + +} // namespace resilience +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/resilience/timeout.h b/third_party/gopher-orch/include/gopher/orch/resilience/timeout.h new file mode 100644 index 00000000..bbe7e8cf --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/resilience/timeout.h @@ -0,0 +1,130 @@ +#pragma once + +// Timeout - Limit execution time for a runnable +// Wraps a runnable and returns error if it doesn't complete within timeout +// +// Behavior: +// - Starts timer when invoke is called +// - Returns TIMEOUT error if timer fires before operation completes +// - Disables timer and returns result if operation completes first +// - Thread-safe handling of race between timer and completion + +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace resilience { + +using namespace gopher::orch::core; + +// Timeout - Wrap a runnable with timeout limit +template +class Timeout : public Runnable { + public: + using RunnablePtr = std::shared_ptr>; + using Callback = typename Runnable::Callback; + + Timeout(RunnablePtr inner, uint64_t timeout_ms) + : inner_(std::move(inner)), timeout_ms_(timeout_ms) {} + + std::string name() const override { + return "Timeout(" + inner_->name() + ", " + std::to_string(timeout_ms_) + + "ms)"; + } + + void invoke(const Input& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + // Create shared state to coordinate between timer and operation + auto state = std::make_shared(std::move(callback)); + + // Start timeout timer + // We need to keep the timer alive, so store it in the state + state->timer = dispatcher.createTimer( + [state, &dispatcher]() { state->onTimeout(dispatcher); }); + state->timer->enableTimer(std::chrono::milliseconds(timeout_ms_)); + + // Invoke inner runnable + inner_->invoke(input, config.child(), dispatcher, + [state, &dispatcher](Result result) { + state->onResult(std::move(result), dispatcher); + }); + } + + // Factory method + static std::shared_ptr> create(RunnablePtr inner, + uint64_t timeout_ms) { + return std::make_shared>(std::move(inner), + timeout_ms); + } + + private: + // Shared state for coordinating between timeout and completion + struct TimeoutState { + Callback callback; + mcp::event::TimerPtr timer; + std::atomic completed{false}; + + explicit TimeoutState(Callback cb) : callback(std::move(cb)) {} + + // Called when the operation completes (success or failure) + void onResult(Result result, Dispatcher& dispatcher) { + bool expected = false; + if (completed.compare_exchange_strong(expected, true)) { + // We won the race - disable timer and deliver result + if (timer) { + timer->disableTimer(); + } + // Post to dispatcher to ensure callback runs in dispatcher context + auto cb = std::move(callback); + dispatcher.post( + [cb = std::move(cb), result = std::move(result)]() mutable { + cb(std::move(result)); + }); + } + // else: timeout already fired, discard result + } + + // Called when the timeout fires + void onTimeout(Dispatcher& dispatcher) { + bool expected = false; + if (completed.compare_exchange_strong(expected, true)) { + // We won the race - deliver timeout error + auto cb = std::move(callback); + dispatcher.post([cb = std::move(cb)]() { + cb(makeOrchError(OrchError::TIMEOUT, "Operation timed out")); + }); + } + // else: operation already completed, ignore timeout + } + }; + + RunnablePtr inner_; + uint64_t timeout_ms_; +}; + +// Convenience alias for JSON timeout +using JsonTimeout = Timeout; + +// Factory function for creating timeout wrapper +template +std::shared_ptr> withTimeout( + std::shared_ptr> inner, uint64_t timeout_ms) { + return Timeout::create(std::move(inner), timeout_ms); +} + +// Factory for JSON timeout +inline std::shared_ptr withTimeout(JsonRunnablePtr inner, + uint64_t timeout_ms) { + return JsonTimeout::create(std::move(inner), timeout_ms); +} + +} // namespace resilience +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/server/gateway_server.h b/third_party/gopher-orch/include/gopher/orch/server/gateway_server.h new file mode 100644 index 00000000..2a86c2c3 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/server/gateway_server.h @@ -0,0 +1,207 @@ +#pragma once + +// GatewayServer - MCP server that exposes tools from multiple backend servers +// +// Creates an MCP server that acts as a gateway/proxy, exposing tools from +// multiple backend MCP servers to external clients through a single endpoint. +// +// Architecture: +// External MCP Clients +// | +// v +// +-------------------+ +// | GatewayServer | (MCP Server on configurable port) +// | | +// | ServerComposite | --> Backend Server 1 (e.g., localhost:3001) +// | | --> Backend Server 2 (e.g., localhost:3002) +// +-------------------+ +// +// Simple Usage: +// std::string serverJson = R"({ +// "succeeded": true, +// "data": { +// "servers": [ +// {"name": "server1", "transport": "http_sse", +// "config": {"url": "http://127.0.0.1:3001/rpc"}} +// ] +// } +// })"; +// +// auto gateway = GatewayServer::create(serverJson); +// gateway->listen(3003); // Blocks until Ctrl+C +// +// Advanced Usage: +// auto composite = ServerComposite::create("backends"); +// composite->addServer(server1, tool_names1, false); +// +// GatewayServerConfig config; +// config.port = 3003; +// +// auto gateway = GatewayServer::create(composite, config); +// gateway->start(dispatcher, callback); + +#include +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" +#include "gopher/orch/server/server_composite.h" +#include "mcp/server/mcp_server.h" + +namespace gopher { +namespace orch { +namespace server { + +using namespace gopher::orch::core; + +// Forward declaration +class GatewayServer; +using GatewayServerPtr = std::shared_ptr; + +// Configuration for GatewayServer +struct GatewayServerConfig { + std::string name = "gateway-server"; + std::string host = "0.0.0.0"; + int port = 3003; + int workers = 4; + int max_sessions = 100; + std::chrono::milliseconds session_timeout{300000}; + std::chrono::milliseconds request_timeout{30000}; + + // HTTP/SSE paths + std::string http_rpc_path = "/mcp"; + std::string http_sse_path = "/events"; + std::string http_health_path = "/health"; +}; + +// GatewayServer - MCP server exposing tools from multiple backend servers +// +// Thread Safety: +// - listen() blocks the calling thread +// - start() should be called once +// - stop() can be called from any thread +// - isRunning() is thread-safe +class GatewayServer : public std::enable_shared_from_this { + public: + using Ptr = std::shared_ptr; + + // ═══════════════════════════════════════════════════════════════════════════ + // SIMPLE API - Create from JSON and listen + // ═══════════════════════════════════════════════════════════════════════════ + + // Create a GatewayServer from JSON server configuration + // The JSON format matches the API response format with "data.servers" array + static Ptr create(const std::string& serverJson, + const GatewayServerConfig& config = {}); + + // Start listening and block until shutdown (Ctrl+C or stop() called) + // This is the simplest way to run the gateway server + // Returns 0 on success, non-zero on error + int listen(int port); + + // ═══════════════════════════════════════════════════════════════════════════ + // ADVANCED API - Create from ServerComposite with async control + // ═══════════════════════════════════════════════════════════════════════════ + + // Create a GatewayServer with an existing ServerComposite + static Ptr create(ServerCompositePtr composite, + const GatewayServerConfig& config = {}) { + return std::shared_ptr( + new GatewayServer(std::move(composite), config)); + } + + // Start the gateway server asynchronously + void start(Dispatcher& dispatcher, std::function callback); + + // Stop the gateway server + void stop(Dispatcher& dispatcher, std::function callback); + + // Stop the gateway server (blocking) + void stop(); + + // ═══════════════════════════════════════════════════════════════════════════ + // ACCESSORS + // ═══════════════════════════════════════════════════════════════════════════ + + ~GatewayServer(); + + // Get the server name + const std::string& name() const { return config_.name; } + + // Get the underlying ServerComposite + ServerCompositePtr getComposite() const { return composite_; } + + // Check if server is running + bool isRunning() const { return running_.load(); } + + // Get the listen address for display (e.g., "0.0.0.0:3003") + std::string getListenAddress() const { + return config_.host + ":" + std::to_string(config_.port); + } + + // Get the listen URL for MCP server (e.g., "http://0.0.0.0:3003") + std::string getListenUrl() const { + return "http://" + config_.host + ":" + std::to_string(config_.port); + } + + // Get the number of registered tools + size_t toolCount() const { return tool_count_.load(); } + + // Get the number of connected backend servers + size_t serverCount() const; + + // Get error message if creation failed + const std::string& getError() const { return error_message_; } + + private: + explicit GatewayServer(ServerCompositePtr composite, + const GatewayServerConfig& config); + + // Private constructor for JSON-based creation (sets error_message_ on failure) + GatewayServer(const GatewayServerConfig& config); + + // Initialize from JSON (called by create()) + bool initFromJson(const std::string& serverJson); + + // Register tools from composite onto the MCP server + void registerToolsFromComposite(); + + // Create a tool handler that routes through the composite + mcp::CallToolResult handleToolCall(const std::string& tool_name, + const mcp::optional& arguments, + mcp::server::SessionContext& session); + + ServerCompositePtr composite_; + GatewayServerConfig config_; + std::unique_ptr mcp_server_; + std::unique_ptr owned_dispatcher_; // For simple API + std::atomic running_{false}; + std::atomic shutdown_requested_{false}; + std::atomic tool_count_{0}; + std::string error_message_; + + // Cached tool info from all servers (includes descriptions and schemas) + std::vector cached_tool_infos_; + + // Background thread for running the backend dispatcher + std::unique_ptr dispatcher_thread_; + std::atomic dispatcher_running_{false}; +}; + +// Convenience function to create a gateway server from JSON +inline GatewayServerPtr makeGatewayServer(const std::string& serverJson, + const GatewayServerConfig& config = {}) { + return GatewayServer::create(serverJson, config); +} + +// Convenience function to create a gateway server from composite +inline GatewayServerPtr makeGatewayServer(ServerCompositePtr composite, + const GatewayServerConfig& config = {}) { + return GatewayServer::create(std::move(composite), config); +} + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/server/mcp_server.h b/third_party/gopher-orch/include/gopher/orch/server/mcp_server.h new file mode 100644 index 00000000..ff8365f8 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/server/mcp_server.h @@ -0,0 +1,202 @@ +#pragma once + +// MCPServer - MCP protocol implementation of Server interface +// +// Wraps the gopher-mcp client to provide a protocol-agnostic Server interface. +// Supports stdio, HTTP+SSE, and WebSocket transports. +// +// Usage: +// MCPServerConfig config; +// config.name = "my-mcp-server"; +// config.transport = MCPServerConfig::StdioTransport{"npx", {"-y", +// "server"}}; +// +// MCPServer::create(config, dispatcher, [](Result result) { +// if (result.isOk()) { +// auto server = result.value(); +// // Use server->tool("tool_name") to get a Runnable +// } +// }); + +#include +#include +#include +#include +#include +#include + +#include "mcp/client/mcp_client.h" +#include "mcp/event/event_loop.h" +#include "mcp/types.h" + +#include "gopher/orch/server/server.h" + +namespace gopher { +namespace orch { +namespace server { + +// Forward declaration +class MCPServer; +using MCPServerPtr = std::shared_ptr; + +// Configuration for MCP server connection +struct MCPServerConfig { + std::string name; // Human-readable name for this server + + // Stdio transport configuration + // Used for subprocess-based MCP servers (most common) + struct StdioTransport { + std::string command; // Command to run + std::vector args; // Command arguments + std::map env; // Environment variables + std::string working_directory; // Working directory (optional) + }; + + // HTTP+SSE transport configuration + // Used for network-based MCP servers + struct HttpSseTransport { + std::string url; // Server URL (e.g., "http://localhost:8080") + std::map headers; // HTTP headers + bool verify_ssl = true; // Verify SSL certificates + }; + + // WebSocket transport configuration (future) + struct WebSocketTransport { + std::string url; // WebSocket URL + std::map headers; // HTTP headers for upgrade + bool verify_ssl = true; // Verify SSL certificates + }; + + // Transport configuration - one of the above + // Use std::variant when C++17 is available, otherwise use tagged union + // pattern + enum class TransportType { STDIO, HTTP_SSE, WEBSOCKET }; + TransportType transport_type = TransportType::STDIO; + StdioTransport stdio_transport; + HttpSseTransport http_sse_transport; + WebSocketTransport websocket_transport; + + // Connection timeouts + std::chrono::milliseconds connect_timeout{30000}; + std::chrono::milliseconds request_timeout{60000}; + + // Retry configuration for initial connection + uint32_t max_connect_retries = 3; + std::chrono::milliseconds retry_delay{1000}; + + // Client info for MCP initialization + std::string client_name = "gopher-orch"; + std::string client_version = "1.0.0"; +}; + +// MCPServer - MCP protocol implementation of Server interface +// +// Thread Safety: +// - All public methods must be called from dispatcher thread +// - Callbacks are invoked in dispatcher thread context +// - connect() initiates async connection, callback when complete +// +// Lifecycle: +// - Create with MCPServer::create() factory method +// - connect() starts connection and protocol initialization +// - Once connected, use tool() to get Runnables for tools +// - disconnect() gracefully shuts down +class MCPServer : public Server { + public: + // Factory method - creates and optionally auto-connects + // + // If auto_connect is true (default), the server will start connecting + // immediately and the callback is invoked when ready or on error. + // + // If auto_connect is false, the callback is invoked immediately with + // the created server, and you must call connect() explicitly. + static void create(const MCPServerConfig& config, + Dispatcher& dispatcher, + std::function)> callback, + bool auto_connect = true); + + ~MCPServer() override; + + // Server interface implementation + std::string id() const override { return id_; } + std::string name() const override { return config_.name; } + ConnectionState connectionState() const override { return state_; } + + void connect(Dispatcher& dispatcher, ConnectionCallback callback) override; + void disconnect(Dispatcher& dispatcher, + std::function callback) override; + + void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) override; + + JsonRunnablePtr tool(const std::string& name) override; + + void callTool(const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) override; + + // MCP-specific accessors + + // Server information from initialization response + const mcp::Implementation& serverInfo() const { return server_info_; } + + // Server capabilities from initialization response + const mcp::ServerCapabilities& capabilities() const { return capabilities_; } + + // Get the underlying MCP client (for advanced usage) + mcp::client::McpClient* client() const { return client_.get(); } + + private: + // Private constructor - use create() factory + explicit MCPServer(const MCPServerConfig& config); + + // Initialize the MCP connection + // Called after create() if auto_connect is true + void initialize(Dispatcher& dispatcher, + std::function)> callback); + + // Handle connection established + void onConnected(Dispatcher& dispatcher, + std::function)> callback); + + // Handle protocol initialization complete + void onInitialized(Dispatcher& dispatcher, + const mcp::InitializeResult& init_result, + std::function)> callback, + std::shared_ptr start_time, + std::chrono::milliseconds timeout); + + // Handle tools listed + void onToolsListed(const mcp::ListToolsResult& tools_result); + + // Convert MCP Tool to ServerToolInfo + static ServerToolInfo toServerToolInfo(const mcp::Tool& tool); + + // Convert MCP content to JsonValue + static JsonValue contentToJson( + const std::vector& content); + + // Generate unique ID + static std::string generateId(); + + std::string id_; + MCPServerConfig config_; + ConnectionState state_ = ConnectionState::DISCONNECTED; + + std::unique_ptr client_; + mcp::Implementation server_info_; + mcp::ServerCapabilities capabilities_; + + // Cached tool information + std::vector tools_; + std::map tool_cache_; + + // Pending callbacks during connection + std::vector> pending_on_connect_; +}; + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/server/mock_server.h b/third_party/gopher-orch/include/gopher/orch/server/mock_server.h new file mode 100644 index 00000000..abbb2c5c --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/server/mock_server.h @@ -0,0 +1,274 @@ +#pragma once + +// MockServer - In-memory server implementation for testing +// +// Provides a server that operates entirely in memory with no network I/O. +// Useful for: +// - Unit testing workflows without network dependencies +// - Mocking specific tool behaviors +// - Recording tool calls for verification +// - Simulating errors and edge cases + +#include +#include +#include +#include +#include + +#include "gopher/orch/server/server.h" + +namespace gopher { +namespace orch { +namespace server { + +// Mock tool response configuration +struct MockToolConfig { + // Response to return on success + optional response; + + // Error to return (if set, overrides response) + optional error; + + // Delay before responding (in milliseconds) + std::chrono::milliseconds delay{0}; + + // Number of calls received + size_t call_count = 0; + + // Last arguments received + optional last_arguments; + + // Custom handler (overrides response/error if set) + std::function(const JsonValue&)> handler; +}; + +// MockServer - In-memory server for testing +class MockServer : public Server { + public: + explicit MockServer(const std::string& name, const std::string& id = "") + : name_(name), + id_(id.empty() ? "mock-" + name : id), + state_(ConnectionState::DISCONNECTED) {} + + // Server interface implementation + std::string id() const override { return id_; } + std::string name() const override { return name_; } + ConnectionState connectionState() const override { return state_; } + + void connect(Dispatcher& dispatcher, ConnectionCallback callback) override { + state_ = ConnectionState::CONNECTED; + dispatcher.post([callback]() { callback(makeSuccess(nullptr)); }); + } + + void disconnect(Dispatcher& dispatcher, + std::function callback) override { + state_ = ConnectionState::DISCONNECTED; + if (callback) { + dispatcher.post(std::move(callback)); + } + } + + void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) override { + std::vector tools; + { + std::lock_guard lock(mutex_); + for (const auto& kv : tools_) { + tools.push_back(kv.second); + } + } + dispatcher.post([tools = std::move(tools), callback]() { + callback(makeSuccess(std::move(tools))); + }); + } + + JsonRunnablePtr tool(const std::string& name) override { + std::lock_guard lock(mutex_); + auto it = tools_.find(name); + if (it == tools_.end()) { + return nullptr; + } + return std::make_shared(shared(), it->second); + } + + void callTool(const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) override { + MockToolConfig* tool_config = nullptr; + { + std::lock_guard lock(mutex_); + auto it = configs_.find(name); + if (it == configs_.end()) { + auto tool_it = tools_.find(name); + if (tool_it == tools_.end()) { + dispatcher.post([name, callback]() { + callback(Result( + Error(OrchError::TOOL_NOT_FOUND, "Tool not found: " + name))); + }); + return; + } + // Create default config for tool + configs_[name] = MockToolConfig(); + configs_[name].response = JsonValue::object(); + it = configs_.find(name); + } + tool_config = &it->second; + tool_config->call_count++; + tool_config->last_arguments = arguments; + } + + // Capture result before posting + Result result = Result(JsonValue::object()); + + if (tool_config->handler) { + result = tool_config->handler(arguments); + } else if (tool_config->error.has_value()) { + result = Result(tool_config->error.value()); + } else if (tool_config->response.has_value()) { + // Copy the response value to avoid reference issues + JsonValue response_copy = tool_config->response.value(); + result = Result(std::move(response_copy)); + } + + auto delay = tool_config->delay; + + if (delay.count() > 0) { + // Create timer for delayed response + auto timer = + dispatcher.createTimer([result = std::move(result), + callback = std::move(callback)]() mutable { + callback(std::move(result)); + }); + timer->enableTimer(delay); + } else { + dispatcher.post([result = std::move(result), + callback = std::move(callback)]() mutable { + callback(std::move(result)); + }); + } + } + + // ========================================================================= + // MockServer-specific API for test configuration + // ========================================================================= + + // Add a tool to the mock server + MockServer& addTool(const std::string& name, + const std::string& description = "") { + std::lock_guard lock(mutex_); + tools_[name] = ServerToolInfo(name, description); + return *this; + } + + // Add a tool with schema + MockServer& addTool(const ServerToolInfo& info) { + std::lock_guard lock(mutex_); + tools_[info.name] = info; + return *this; + } + + // Set the response for a tool + MockServer& setResponse(const std::string& toolName, + const JsonValue& response) { + std::lock_guard lock(mutex_); + configs_[toolName].response = response; + configs_[toolName].error = nullopt; + return *this; + } + + // Set an error response for a tool + MockServer& setError(const std::string& toolName, const Error& error) { + std::lock_guard lock(mutex_); + configs_[toolName].error = error; + return *this; + } + + MockServer& setError(const std::string& toolName, + int code, + const std::string& message) { + return setError(toolName, Error(code, message)); + } + + // Set a delay before responding + MockServer& setDelay(const std::string& toolName, + std::chrono::milliseconds delay) { + std::lock_guard lock(mutex_); + configs_[toolName].delay = delay; + return *this; + } + + // Set a custom handler for a tool + MockServer& setHandler( + const std::string& toolName, + std::function(const JsonValue&)> handler) { + std::lock_guard lock(mutex_); + configs_[toolName].handler = std::move(handler); + return *this; + } + + // Get call count for a tool + size_t callCount(const std::string& toolName) const { + std::lock_guard lock(mutex_); + auto it = configs_.find(toolName); + if (it == configs_.end()) { + return 0; + } + return it->second.call_count; + } + + // Get total call count for all tools + size_t totalCallCount() const { + std::lock_guard lock(mutex_); + size_t total = 0; + for (const auto& kv : configs_) { + total += kv.second.call_count; + } + return total; + } + + // Get last arguments for a tool + optional lastArguments(const std::string& toolName) const { + std::lock_guard lock(mutex_); + auto it = configs_.find(toolName); + if (it == configs_.end()) { + return nullopt; + } + return it->second.last_arguments; + } + + // Reset all call counts + void resetCallCounts() { + std::lock_guard lock(mutex_); + for (auto& kv : configs_) { + kv.second.call_count = 0; + kv.second.last_arguments = nullopt; + } + } + + // Clear all tools and configs + void clear() { + std::lock_guard lock(mutex_); + tools_.clear(); + configs_.clear(); + } + + private: + mutable std::mutex mutex_; + std::string name_; + std::string id_; + ConnectionState state_; + std::map tools_; + std::map configs_; +}; + +// Factory function +inline std::shared_ptr makeMockServer(const std::string& name, + const std::string& id = "") { + return std::make_shared(name, id); +} + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/server/rest_server.h b/third_party/gopher-orch/include/gopher/orch/server/rest_server.h new file mode 100644 index 00000000..4a35c9ed --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/server/rest_server.h @@ -0,0 +1,333 @@ +#pragma once + +// RESTServer - REST API implementation of Server interface +// +// Provides a Server implementation that wraps REST API endpoints as tools. +// Each tool maps to an HTTP endpoint with configurable method, path, and +// schema. +// +// Usage: +// RESTServerConfig config; +// config.name = "api-server"; +// config.base_url = "https://api.example.com/v1"; +// config.addTool("get_user", "GET", "/users/{id}", "Get user by ID"); +// config.addTool("create_user", "POST", "/users", "Create a new user"); +// +// auto server = RESTServer::create(config); +// auto getUserTool = server->tool("get_user"); +// +// Path parameters are substituted from the input JSON: +// /users/{id} with input {"id": "123"} becomes /users/123 + +#include +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/server/server.h" + +namespace gopher { +namespace orch { +namespace server { + +// Forward declarations +class RESTServer; +using RESTServerPtr = std::shared_ptr; + +// HTTP method enumeration +enum class HttpMethod { + GET, + POST, + PUT, + PATCH, + DELETE_, // DELETE is a macro on some platforms + HEAD, + OPTIONS +}; + +// Convert HttpMethod to string +inline std::string httpMethodToString(HttpMethod method) { + switch (method) { + case HttpMethod::GET: + return "GET"; + case HttpMethod::POST: + return "POST"; + case HttpMethod::PUT: + return "PUT"; + case HttpMethod::PATCH: + return "PATCH"; + case HttpMethod::DELETE_: + return "DELETE"; + case HttpMethod::HEAD: + return "HEAD"; + case HttpMethod::OPTIONS: + return "OPTIONS"; + default: + return "GET"; + } +} + +// Parse string to HttpMethod +inline HttpMethod parseHttpMethod(const std::string& method) { + if (method == "GET") + return HttpMethod::GET; + if (method == "POST") + return HttpMethod::POST; + if (method == "PUT") + return HttpMethod::PUT; + if (method == "PATCH") + return HttpMethod::PATCH; + if (method == "DELETE") + return HttpMethod::DELETE_; + if (method == "HEAD") + return HttpMethod::HEAD; + if (method == "OPTIONS") + return HttpMethod::OPTIONS; + return HttpMethod::GET; +} + +// Tool endpoint configuration +struct RESTToolEndpoint { + HttpMethod method = HttpMethod::GET; + std::string path; // e.g., "/users/{id}" + ServerToolInfo info; // Tool metadata + + // Request body handling + bool send_body = + true; // Send input JSON as request body (for POST/PUT/PATCH) + + // Response handling + std::string response_json_path; // JSONPath to extract from response (empty = + // use whole response) + + RESTToolEndpoint() = default; + RESTToolEndpoint(HttpMethod m, const std::string& p, const ServerToolInfo& i) + : method(m), + path(p), + info(i), + send_body(m != HttpMethod::GET && m != HttpMethod::DELETE_) {} +}; + +// Configuration for REST server connection +struct RESTServerConfig { + std::string name; // Human-readable name + std::string base_url; // Base URL (e.g., "https://api.example.com/v1") + + // Default headers for all requests + std::map default_headers; + + // Authentication + struct AuthConfig { + enum class Type { NONE, BEARER, BASIC, API_KEY }; + Type type = Type::NONE; + + std::string bearer_token; // For BEARER auth + std::string username; // For BASIC auth + std::string password; // For BASIC auth + std::string api_key; // For API_KEY auth + std::string api_key_header = "X-API-Key"; // Header name for API key + }; + AuthConfig auth; + + // Timeouts + std::chrono::milliseconds connect_timeout{10000}; + std::chrono::milliseconds request_timeout{30000}; + + // SSL/TLS + bool verify_ssl = true; + std::string ca_cert_path; // Optional CA certificate path + + // Tool endpoint mappings + std::map tools; + + // Fluent API for adding tools + RESTServerConfig& addTool(const std::string& name, + HttpMethod method, + const std::string& path, + const std::string& description = "") { + RESTToolEndpoint endpoint; + endpoint.method = method; + endpoint.path = path; + endpoint.info.name = name; + endpoint.info.description = description; + tools[name] = endpoint; + return *this; + } + + RESTServerConfig& addTool(const std::string& name, + const std::string& method, + const std::string& path, + const std::string& description = "") { + return addTool(name, parseHttpMethod(method), path, description); + } + + RESTServerConfig& setHeader(const std::string& name, + const std::string& value) { + default_headers[name] = value; + return *this; + } + + RESTServerConfig& setBearerAuth(const std::string& token) { + auth.type = AuthConfig::Type::BEARER; + auth.bearer_token = token; + return *this; + } + + RESTServerConfig& setBasicAuth(const std::string& username, + const std::string& password) { + auth.type = AuthConfig::Type::BASIC; + auth.username = username; + auth.password = password; + return *this; + } + + RESTServerConfig& setApiKey(const std::string& key, + const std::string& header = "X-API-Key") { + auth.type = AuthConfig::Type::API_KEY; + auth.api_key = key; + auth.api_key_header = header; + return *this; + } +}; + +// HTTP response from REST call +struct HttpResponse { + int status_code = 0; + std::map headers; + std::string body; + + bool isSuccess() const { return status_code >= 200 && status_code < 300; } + bool isClientError() const { return status_code >= 400 && status_code < 500; } + bool isServerError() const { return status_code >= 500; } +}; + +// HTTP client interface - abstraction for making HTTP requests +// This allows different implementations (libevent, curl, etc.) +class HttpClient { + public: + using ResponseCallback = std::function)>; + + virtual ~HttpClient() = default; + + // Make an HTTP request asynchronously + virtual void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + ResponseCallback callback) = 0; +}; + +using HttpClientPtr = std::shared_ptr; + +// RESTServer - REST API implementation of Server interface +// +// Thread Safety: +// - Configuration should be done before use +// - All public methods are thread-safe after configuration +// - Callbacks are invoked in dispatcher thread context +class RESTServer : public Server { + public: + using Ptr = std::shared_ptr; + + // Factory method - creates a REST server with default HTTP client + static Ptr create(const RESTServerConfig& config); + + // Factory method with custom HTTP client + static Ptr create(const RESTServerConfig& config, HttpClientPtr http_client); + + ~RESTServer() override; + + // Server interface implementation + std::string id() const override { return id_; } + std::string name() const override { return config_.name; } + ConnectionState connectionState() const override { return state_; } + + void connect(Dispatcher& dispatcher, ConnectionCallback callback) override; + void disconnect(Dispatcher& dispatcher, + std::function callback) override; + + void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) override; + + JsonRunnablePtr tool(const std::string& name) override; + + void callTool(const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) override; + + // REST-specific methods + + // Get the configuration + const RESTServerConfig& config() const { return config_; } + + // Update authentication at runtime + void setAuth(const RESTServerConfig::AuthConfig& auth); + + // Add a header that will be sent with all requests + void setDefaultHeader(const std::string& name, const std::string& value); + + private: + explicit RESTServer(const RESTServerConfig& config, + HttpClientPtr http_client); + + // Build full URL from endpoint path and arguments + std::string buildUrl(const std::string& path, const JsonValue& args) const; + + // Build request headers including auth + std::map buildHeaders() const; + + // Generate unique ID + static std::string generateId(); + + std::string id_; + RESTServerConfig config_; + HttpClientPtr http_client_; + ConnectionState state_ = ConnectionState::DISCONNECTED; + + // Cached tool runnables + std::map tool_cache_; + mutable std::mutex mutex_; +}; + +// Default HTTP client implementation using gopher-mcp networking +// Note: This is a basic implementation. For production use, consider +// using a more robust HTTP client library. +class DefaultHttpClient : public HttpClient { + public: + DefaultHttpClient(); + ~DefaultHttpClient() override; + + void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + ResponseCallback callback) override; + + private: + class Impl; + std::unique_ptr impl_; +}; + +// Factory function +inline RESTServerPtr makeRESTServer(const RESTServerConfig& config) { + return RESTServer::create(config); +} + +// Factory for CurlHttpClient implementation +std::shared_ptr createCurlHttpClient(); + +// Utility function for making synchronous JSON HTTP GET requests using CurlHttpClient +std::string fetchJsonSync(const std::string& url, + const std::map& headers = {}); + + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/server/server.h b/third_party/gopher-orch/include/gopher/orch/server/server.h new file mode 100644 index 00000000..498fa9ca --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/server/server.h @@ -0,0 +1,142 @@ +#pragma once + +// Server - Protocol-agnostic server abstraction +// +// Defines a common interface for interacting with tool-providing servers +// regardless of the underlying protocol (MCP, REST, gRPC, mock, etc.) +// +// Key abstractions: +// - Server: Connection to a tool provider +// - ServerTool: A tool exposed by the server (implements Runnable) +// - ServerToolInfo: Metadata about a tool from a server + +#include +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace server { + +using namespace gopher::orch::core; + +// Forward declarations +class Server; +class ServerTool; + +using ServerPtr = std::shared_ptr; +using ServerToolPtr = std::shared_ptr; + +// Information about a tool exposed by a server +struct ServerToolInfo { + std::string name; + std::string description; + JsonValue inputSchema; // JSON Schema for tool arguments + + ServerToolInfo() = default; + ServerToolInfo(const std::string& n, const std::string& desc = "") + : name(n), description(desc), inputSchema(JsonValue::object()) {} +}; + +// Connection state for server +enum class ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + RECONNECTING, + FAILED +}; + +// Callback types +using ConnectionCallback = std::function)>; +using ServerToolListCallback = + std::function>)>; + +// Server - Abstract interface for protocol-agnostic server access +// +// Implementations: +// - MockServer: For testing without network +// - MCPServer: For MCP protocol (stdio, SSE, WebSocket) +// - RESTServer: For REST API endpoints +class Server : public std::enable_shared_from_this { + public: + virtual ~Server() = default; + + // Unique identifier for this server instance + virtual std::string id() const = 0; + + // Human-readable name + virtual std::string name() const = 0; + + // Current connection state + virtual ConnectionState connectionState() const = 0; + + // Check if connected + bool isConnected() const { + return connectionState() == ConnectionState::CONNECTED; + } + + // Connect to the server (async) + // Callback invoked in dispatcher context when connection completes or fails + virtual void connect(Dispatcher& dispatcher, ConnectionCallback callback) = 0; + + // Disconnect from the server (async) + virtual void disconnect(Dispatcher& dispatcher, + std::function callback = nullptr) = 0; + + // List available tools (async) + // May return cached list if already connected + virtual void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) = 0; + + // Get a tool by name as a Runnable + // Returns nullptr if tool not found + virtual JsonRunnablePtr tool(const std::string& name) = 0; + + // Call a tool directly (convenience method) + // Equivalent to tool(name)->invoke(...) + virtual void callTool(const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) = 0; + + // Get shared pointer to this server + ServerPtr shared() { return shared_from_this(); } + + protected: + Server() = default; +}; + +// ServerTool - A tool exposed by a server, implements Runnable +// +// Wraps a tool call through the server's protocol +class ServerTool : public JsonRunnable { + public: + ServerTool(ServerPtr server, const ServerToolInfo& info) + : server_(std::move(server)), info_(info) {} + + std::string name() const override { return info_.name; } + + const ServerToolInfo& info() const { return info_; } + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + server_->callTool(info_.name, input, config, dispatcher, + std::move(callback)); + } + + private: + ServerPtr server_; + ServerToolInfo info_; +}; + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/include/gopher/orch/server/server_composite.h b/third_party/gopher-orch/include/gopher/orch/server/server_composite.h new file mode 100644 index 00000000..baf4ac28 --- /dev/null +++ b/third_party/gopher-orch/include/gopher/orch/server/server_composite.h @@ -0,0 +1,412 @@ +#pragma once + +// ServerComposite - Aggregate tools from multiple servers +// +// Provides a unified view of tools from multiple servers, regardless of +// their underlying protocol (MCP, REST, Mock, etc.). +// +// Features: +// - Namespace tools by server name to avoid conflicts +// - Support tool aliasing for cleaner API +// - Lazy connection: servers connect when their tools are first used +// - Tool discovery across all registered servers +// +// Usage: +// auto composite = ServerComposite::create("my-tools"); +// composite->addServer(mcp_server); +// composite->addServer(rest_server); +// +// // Get tool with automatic server routing +// auto tool = composite->tool("weather.get_forecast"); +// +// // Or with explicit server name +// auto tool = composite->tool("mcp-server", "get_forecast"); + +#include +#include +#include +#include + +#include "gopher/orch/server/server.h" + +namespace gopher { +namespace orch { +namespace server { + +// Forward declaration +class ServerComposite; +using ServerCompositePtr = std::shared_ptr; + +// Configuration for how tools are exposed +struct ToolMapping { + std::string server_name; // Source server + std::string tool_name; // Tool name on server + std::string alias; // Exposed name (empty = use tool_name) + + ToolMapping() = default; + ToolMapping(const std::string& server, + const std::string& tool, + const std::string& alias_name = "") + : server_name(server), tool_name(tool), alias(alias_name) {} +}; + +// ServerComposite - Aggregates tools from multiple servers +// +// This class provides a unified interface to tools from multiple servers. +// Tools can be accessed either by their fully-qualified name (server.tool) +// or by alias if configured. +// +// Thread Safety: +// - Thread-safe for read operations (listTools, tool) +// - Not thread-safe for write operations (addServer, addTool) +// - Write operations should be done during initialization +class ServerComposite : public std::enable_shared_from_this { + public: + using Ptr = std::shared_ptr; + + // Create a new ServerComposite + static Ptr create(const std::string& name) { + return std::shared_ptr(new ServerComposite(name)); + } + + // Get the composite name + const std::string& name() const { return name_; } + + // Add a server and expose all its tools + // Tools are namespaced as "server_name.tool_name" + // If namespace_tools is false, tools are exposed without prefix + ServerComposite& addServer(ServerPtr server, bool namespace_tools = true); + + // Add a server with specific tools only + ServerComposite& addServer(ServerPtr server, + const std::vector& tool_names, + bool namespace_tools = true); + + // Add a server with tool aliases + ServerComposite& addServerWithAliases( + ServerPtr server, const std::map& aliases); + + // Add a specific tool with optional alias + ServerComposite& addTool(ServerPtr server, + const std::string& tool_name, + const std::string& alias = ""); + + // Remove a server and all its tools + void removeServer(const std::string& server_name); + + // Get a tool by name + // Supports: + // - Fully-qualified name: "server_name.tool_name" + // - Alias: "my_alias" + // - Direct name if unique: "tool_name" + JsonRunnablePtr tool(const std::string& name); + + // Get a tool by server and tool name + JsonRunnablePtr tool(const std::string& server_name, + const std::string& tool_name); + + // List all available tools (with their exposed names) + std::vector listTools() const; + + // List all available tools with full info + std::vector listToolInfos() const; + + // Get all registered servers + const std::map& servers() const { return servers_; } + + // Get server by name + ServerPtr server(const std::string& name) const; + + // Check if a tool exists + bool hasTool(const std::string& name) const; + + // Connect all servers + // Calls connect() on each server and invokes callback when all complete + void connectAll(Dispatcher& dispatcher, + std::function)> callback); + + // Disconnect all servers + void disconnectAll(Dispatcher& dispatcher, std::function callback); + + private: + explicit ServerComposite(const std::string& name) : name_(name) {} + + // Resolve tool name to server and actual tool name + // Returns {server_ptr, tool_name} or {nullptr, ""} if not found + std::pair resolveToolName( + const std::string& name) const; + + std::string name_; + std::map servers_; + + // Tool mappings: exposed_name -> {server_name, actual_tool_name} + std::map> tool_mappings_; + + // Cached tool runnables + mutable std::map tool_cache_; +}; + +// CompositeServerTool - A tool that routes through ServerComposite +// +// This wrapper handles tool resolution and caching at the composite level. +class CompositeServerTool : public JsonRunnable { + public: + CompositeServerTool(ServerCompositePtr composite, + const std::string& exposed_name, + ServerPtr server, + const std::string& tool_name) + : composite_(std::move(composite)), + exposed_name_(exposed_name), + server_(std::move(server)), + tool_name_(tool_name) {} + + std::string name() const override { return exposed_name_; } + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + server_->callTool(tool_name_, input, config, dispatcher, + std::move(callback)); + } + + private: + ServerCompositePtr composite_; + std::string exposed_name_; + ServerPtr server_; + std::string tool_name_; +}; + +// Implementation + +inline ServerComposite& ServerComposite::addServer(ServerPtr server, + bool namespace_tools) { + std::string server_name = server->name(); + servers_[server_name] = server; + + // We can't list tools synchronously here since server might not be connected + // Instead, we mark that we need to discover tools lazily + // For now, assume tools are already known (via listTools cache) + + return *this; +} + +inline ServerComposite& ServerComposite::addServer( + ServerPtr server, + const std::vector& tool_names, + bool namespace_tools) { + std::string server_name = server->name(); + servers_[server_name] = server; + + for (const auto& tool_name : tool_names) { + std::string exposed = + namespace_tools ? server_name + "." + tool_name : tool_name; + tool_mappings_[exposed] = {server_name, tool_name}; + } + + return *this; +} + +inline ServerComposite& ServerComposite::addServerWithAliases( + ServerPtr server, const std::map& aliases) { + std::string server_name = server->name(); + servers_[server_name] = server; + + for (const auto& entry : aliases) { + // entry.first = alias, entry.second = tool_name + tool_mappings_[entry.first] = {server_name, entry.second}; + } + + return *this; +} + +inline ServerComposite& ServerComposite::addTool(ServerPtr server, + const std::string& tool_name, + const std::string& alias) { + std::string server_name = server->name(); + servers_[server_name] = server; + + std::string exposed = alias.empty() ? tool_name : alias; + tool_mappings_[exposed] = {server_name, tool_name}; + + return *this; +} + +inline void ServerComposite::removeServer(const std::string& server_name) { + servers_.erase(server_name); + + // Remove tool mappings for this server + auto it = tool_mappings_.begin(); + while (it != tool_mappings_.end()) { + if (it->second.first == server_name) { + // Also remove from cache + tool_cache_.erase(it->first); + it = tool_mappings_.erase(it); + } else { + ++it; + } + } +} + +inline std::pair ServerComposite::resolveToolName( + const std::string& name) const { + // First check explicit mappings + auto mapping_it = tool_mappings_.find(name); + if (mapping_it != tool_mappings_.end()) { + auto server_it = servers_.find(mapping_it->second.first); + if (server_it != servers_.end()) { + return {server_it->second, mapping_it->second.second}; + } + } + + // Check for fully-qualified name (server.tool) + auto dot_pos = name.find('.'); + if (dot_pos != std::string::npos) { + std::string server_name = name.substr(0, dot_pos); + std::string tool_name = name.substr(dot_pos + 1); + + auto server_it = servers_.find(server_name); + if (server_it != servers_.end()) { + return {server_it->second, tool_name}; + } + } + + // Try each server for a direct tool name match + for (const auto& entry : servers_) { + // This would require checking if the server has this tool + // For now, we return the first server that might have it + // A proper implementation would check tool availability + } + + return {nullptr, ""}; +} + +inline JsonRunnablePtr ServerComposite::tool(const std::string& name) { + // Check cache + auto cache_it = tool_cache_.find(name); + if (cache_it != tool_cache_.end()) { + return cache_it->second; + } + + // Resolve and create + auto resolved = resolveToolName(name); + if (!resolved.first) { + return nullptr; + } + + auto tool_ptr = std::make_shared( + std::const_pointer_cast( + std::static_pointer_cast(shared_from_this())), + name, resolved.first, resolved.second); + + tool_cache_[name] = tool_ptr; + return tool_ptr; +} + +inline JsonRunnablePtr ServerComposite::tool(const std::string& server_name, + const std::string& tool_name) { + std::string full_name = server_name + "." + tool_name; + return tool(full_name); +} + +inline std::vector ServerComposite::listTools() const { + std::vector result; + result.reserve(tool_mappings_.size()); + + for (const auto& entry : tool_mappings_) { + result.push_back(entry.first); + } + + return result; +} + +inline std::vector ServerComposite::listToolInfos() const { + std::vector result; + + for (const auto& entry : tool_mappings_) { + ServerToolInfo info; + info.name = entry.first; + + // Try to get description from server + auto server_it = servers_.find(entry.second.first); + if (server_it != servers_.end()) { + // Would need to query server for tool info + // For now, leave description empty + } + + result.push_back(info); + } + + return result; +} + +inline ServerPtr ServerComposite::server(const std::string& name) const { + auto it = servers_.find(name); + return it != servers_.end() ? it->second : nullptr; +} + +inline bool ServerComposite::hasTool(const std::string& name) const { + return resolveToolName(name).first != nullptr; +} + +inline void ServerComposite::connectAll( + Dispatcher& dispatcher, + std::function)> callback) { + if (servers_.empty()) { + dispatcher.post( + [callback]() { callback(core::makeSuccess(nullptr)); }); + return; + } + + // Track connection results + auto pending = std::make_shared>(servers_.size()); + auto has_error = std::make_shared>(false); + auto first_error = std::make_shared(); + + for (const auto& entry : servers_) { + entry.second->connect(dispatcher, [pending, has_error, first_error, + callback, &dispatcher]( + Result result) { + if (core::isError(result) && !has_error->exchange(true)) { + *first_error = core::getError(result); + } + + if (--(*pending) == 0) { + // All servers done + dispatcher.post([callback, has_error, first_error]() { + if (*has_error) { + callback(Result(*first_error)); + } else { + callback(core::makeSuccess(nullptr)); + } + }); + } + }); + } +} + +inline void ServerComposite::disconnectAll(Dispatcher& dispatcher, + std::function callback) { + if (servers_.empty()) { + if (callback) { + dispatcher.post(callback); + } + return; + } + + auto pending = std::make_shared>(servers_.size()); + + for (const auto& entry : servers_) { + entry.second->disconnect(dispatcher, [pending, callback, &dispatcher]() { + if (--(*pending) == 0) { + if (callback) { + dispatcher.post(callback); + } + } + }); + } +} + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/include/orch/core/hello.h b/third_party/gopher-orch/include/orch/core/hello.h similarity index 100% rename from include/orch/core/hello.h rename to third_party/gopher-orch/include/orch/core/hello.h diff --git a/include/orch/core/version.h b/third_party/gopher-orch/include/orch/core/version.h similarity index 100% rename from include/orch/core/version.h rename to third_party/gopher-orch/include/orch/core/version.h diff --git a/third_party/gopher-orch/sdk/typescript/README.md b/third_party/gopher-orch/sdk/typescript/README.md new file mode 100644 index 00000000..c2b95963 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/README.md @@ -0,0 +1,120 @@ +# Gopher-Orch TypeScript SDK + +TypeScript SDK for the gopher-orch orchestration framework, providing agent capabilities through FFI bindings. + +## Features + +- **ReActAgent**: AI agent with reasoning and acting capabilities +- **Remote API Integration**: Fetch server configurations from remote APIs +- **Local Configuration**: Use JSON-based server configurations +- **Error Handling**: Comprehensive error types and handling +- **TypeScript Support**: Full type safety and IntelliSense support + +## Installation + +```bash +npm install gopher-orch-sdk +``` + +## Quick Start + +### Using Local JSON Configuration + +```typescript +import { ReActAgent, ServerConfigHelper } from 'gopher-orch-sdk'; + +// Create agent with local server configuration +const serverConfig = ServerConfigHelper.createDefaultConfig(); +const agent = ReActAgent.createByJson('AnthropicProvider', 'claude-3-haiku-20240307', serverConfig); + +// Run a query +const response = agent.run('What tools are available?'); +console.log(response); + +// Clean up +agent.dispose(); +``` + +### Using Remote API + +```typescript +import { ReActAgent } from 'gopher-orch-sdk'; + +// Create agent that fetches configuration from remote API +const agent = ReActAgent.createByApiKey('AnthropicProvider', 'claude-3-haiku-20240307', 'your-api-key'); + +// Run queries +const response1 = agent.run('What time is it in Tokyo?'); +const response2 = agent.run('Generate a secure password'); + +console.log('Response 1:', response1); +console.log('Response 2:', response2); + +// Clean up +agent.dispose(); +``` + +## API Reference + +### ReActAgent + +#### Static Methods + +- `createByJson(provider: string, model: string, serverConfig: string): ReActAgent | null` + - Create agent from JSON server configuration + +- `createByApiKey(provider: string, model: string, apiKey: string): ReActAgent | null` + - Create agent by fetching configuration from remote API + +#### Instance Methods + +- `run(query: string, timeoutMs?: number): string` + - Execute a query synchronously + - Default timeout: 60 seconds + +- `runDetailed(query: string, timeoutMs?: number): AgentResult` + - Execute query with detailed result information + +- `dispose(): void` + - Clean up agent resources + +- `isDisposed(): boolean` + - Check if agent has been disposed + +### Error Types + +- `AgentError`: Base error class for all agent-related errors +- `ApiKeyError`: Invalid or missing API key +- `ConnectionError`: Failed to connect to MCP servers +- `TimeoutError`: Agent execution timed out + +### ServerConfigHelper + +- `fetchMcpServers(apiKey: string): Promise` + - Fetch server configurations from remote API + +- `createDefaultConfig(): string` + - Create default local server configuration for development + +## Examples + +See the `/examples/sdk/typescript/` directory for complete examples: + +- `clientExampleSimple.ts` - Basic usage with local configuration +- `clientExampleApi.ts` - Remote API integration + +## Requirements + +- Node.js 16+ +- The gopher-orch native library must be available +- For remote API features: valid API key and network connection + +## Building + +```bash +npm run build +``` + +## License + +MIT \ No newline at end of file diff --git a/third_party/gopher-orch/sdk/typescript/package-lock.json b/third_party/gopher-orch/sdk/typescript/package-lock.json new file mode 100644 index 00000000..46563466 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/package-lock.json @@ -0,0 +1,3690 @@ +{ + "name": "gopher-orch-sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gopher-orch-sdk", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "koffi": "^2.8.0" + }, + "devDependencies": { + "@types/jest": "^29.0.0", + "@types/node": "^18.0.0", + "jest": "^29.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/koffi": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.0.tgz", + "integrity": "sha512-174BTuWK7L42Om7nDWy9YOTXj6Dkm14veuFf5yhVS5VU6GjtOI1Wjf+K16Z0JvSuZ3/NpkVzFBjE1oKbthTIEA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://buymeacoffee.com/koromix" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/third_party/gopher-orch/sdk/typescript/package.json b/third_party/gopher-orch/sdk/typescript/package.json new file mode 100644 index 00000000..2ec2f1e1 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/package.json @@ -0,0 +1,38 @@ +{ + "name": "gopher-orch-sdk", + "version": "1.0.0", + "description": "TypeScript SDK for gopher-orch orchestration framework", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "jest", + "clean": "rm -rf dist" + }, + "dependencies": { + "koffi": "^2.8.0" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "typescript": "^5.0.0", + "jest": "^29.0.0", + "@types/jest": "^29.0.0" + }, + "keywords": [ + "orchestration", + "agent", + "ai", + "mcp", + "typescript" + ], + "author": "Gopher Security", + "license": "MIT" +} \ No newline at end of file diff --git a/third_party/gopher-orch/sdk/typescript/src/agent.ts b/third_party/gopher-orch/sdk/typescript/src/agent.ts new file mode 100644 index 00000000..d18f8eca --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/src/agent.ts @@ -0,0 +1,346 @@ +/** + * @file agent.ts + * @brief TypeScript wrapper for GopherAgent functionality + */ + +import { library, initializeLibrary, shutdownLibrary, getLastError, clearError } from './ffi.js'; +import { + AgentConfig, + AgentResult, + AgentError, + ApiKeyError, + ConnectionError, + TimeoutError, + ApiResponse +} from './types.js'; + +/** + * Configuration options for creating a GopherAgent + */ +export interface GopherAgentConfig { + /** Provider name (e.g., "AnthropicProvider") */ + provider: string; + /** Model name (e.g., "claude-3-haiku-20240307") */ + model: string; + /** API key for fetching remote server config (mutually exclusive with serverConfig) */ + apiKey?: string; + /** JSON server configuration (mutually exclusive with apiKey) */ + serverConfig?: string; +} + +/** + * GopherAgent - Main entry point for the gopher-orch TypeScript SDK + * + * Provides a clean, TypeScript-friendly interface to the gopher-orch agent functionality. + * + * @example + * ```typescript + * import { GopherAgent } from "gopher-orch-sdk"; + * + * // Initialize the library + * GopherAgent.init(); + * + * // Create an agent with API key + * const agent = GopherAgent.create({ + * provider: 'AnthropicProvider', + * model: 'claude-3-haiku-20240307', + * apiKey: 'your-api-key' + * }); + * + * // Run a query + * const answer = agent.run("What time is it in Tokyo?"); + * console.log(answer); + * + * // Cleanup (optional - happens automatically on exit) + * agent.dispose(); + * ``` + */ +export class GopherAgent { + private handle: any; + private disposed: boolean = false; + private static initialized: boolean = false; + + private constructor(handle: any) { + this.handle = handle; + } + + /** + * Initialize the gopher-orch library + * Must be called before creating any agents + * + * @throws {AgentError} If initialization fails + */ + static init(): void { + if (GopherAgent.initialized) { + return; + } + + const success = initializeLibrary(); + if (!success) { + throw new AgentError('Failed to initialize gopher-orch library'); + } + + library.gopher_orch_init(); + GopherAgent.initialized = true; + + // Setup automatic cleanup on process exit + GopherAgent.setupCleanupHandlers(); + } + + /** + * Shutdown the gopher-orch library + * Called automatically on process exit, but can be called manually + */ + static shutdown(): void { + if (GopherAgent.initialized) { + shutdownLibrary(); + GopherAgent.initialized = false; + } + } + + /** + * Check if the library is initialized + */ + static isInitialized(): boolean { + return GopherAgent.initialized; + } + + /** + * Create a new GopherAgent instance + * + * @param config Configuration options + * @returns GopherAgent instance + * @throws {AgentError} If agent creation fails + * + * @example + * ```typescript + * // Create with API key (fetches server config from remote API) + * const agent = GopherAgent.create({ + * provider: 'AnthropicProvider', + * model: 'claude-3-haiku-20240307', + * apiKey: 'your-api-key' + * }); + * + * // Or create with JSON server config + * const agent = GopherAgent.create({ + * provider: 'AnthropicProvider', + * model: 'claude-3-haiku-20240307', + * serverConfig: '{"succeeded": true, "data": {...}}' + * }); + * ``` + */ + static create(config: GopherAgentConfig): GopherAgent { + if (!GopherAgent.initialized) { + GopherAgent.init(); + } + + const { provider, model, apiKey, serverConfig } = config; + + if (!provider || !model) { + throw new AgentError('Provider and model are required'); + } + + if (apiKey && serverConfig) { + throw new AgentError('Cannot specify both apiKey and serverConfig'); + } + + if (!apiKey && !serverConfig) { + throw new AgentError('Either apiKey or serverConfig is required'); + } + + let handle: any; + + try { + if (apiKey) { + handle = library.gopher_orch_agent_create_by_api_key(provider, model, apiKey); + } else { + handle = library.gopher_orch_agent_create_by_json(provider, model, serverConfig!); + } + + if (!handle || (handle.isNull && handle.isNull())) { + // Try to get error message from FFI layer + const lastError = library.gopher_orch_last_error(); + const errorMsg = lastError ? String(lastError) : 'Failed to create agent'; + library.gopher_orch_clear_error(); + throw new AgentError(errorMsg); + } + + return new GopherAgent(handle); + } catch (error: any) { + if (error instanceof AgentError) { + throw error; + } + throw new AgentError(`Failed to create agent: ${error.message}`); + } + } + + /** + * Run a query against the agent + * + * @param query The user query to process + * @param timeoutMs Optional timeout in milliseconds (default: 60000) + * @returns The agent's response + * @throws {AgentError} If the query fails + */ + run(query: string, timeoutMs: number = 60000): string { + this.ensureNotDisposed(); + + try { + const response = library.gopher_orch_agent_run(this.handle, query, timeoutMs); + return response; + } catch (error: any) { + throw new AgentError(`Query execution failed: ${error.message}`); + } + } + + /** + * Run a query with detailed result information + * + * @param query The user query to process + * @param timeoutMs Optional timeout in milliseconds + * @returns AgentResult with response and metadata + */ + runDetailed(query: string, timeoutMs: number = 60000): AgentResult { + try { + const response = this.run(query, timeoutMs); + + return { + response, + status: 'success', + iterationCount: 1, + tokensUsed: 0, + }; + } catch (error: any) { + if (error instanceof TimeoutError) { + return { + response: error.message, + status: 'timeout' + }; + } else { + return { + response: error.message, + status: 'error' + }; + } + } + } + + /** + * Dispose of the agent and free resources + */ + dispose(): void { + if (!this.disposed) { + if (this.handle) { + library.gopher_orch_agent_release(this.handle); + this.handle = null; + } + this.disposed = true; + } + } + + /** + * Check if agent is disposed + */ + isDisposed(): boolean { + return this.disposed; + } + + private ensureNotDisposed(): void { + if (this.disposed) { + throw new AgentError('Agent has been disposed'); + } + } + + private static setupCleanupHandlers(): void { + const cleanup = () => { + GopherAgent.shutdown(); + }; + + process.on('exit', cleanup); + process.on('SIGTERM', () => { + cleanup(); + process.exit(0); + }); + process.on('SIGINT', () => { + cleanup(); + process.exit(0); + }); + } +} + +// Keep ReActAgent as an alias for backward compatibility +export { GopherAgent as ReActAgent }; + +/** + * Utility functions for working with server configurations + */ +export class ServerConfig { + /** + * Fetch MCP server configurations from remote API + * + * @param apiKey API key for authentication + * @returns Server configuration JSON string + */ + static fetch(apiKey: string): string { + if (!GopherAgent.isInitialized()) { + GopherAgent.init(); + } + + try { + if (!apiKey || apiKey.trim().length === 0) { + throw new ApiKeyError('Invalid or missing API key'); + } + + return library.gopher_orch_api_fetch_servers(apiKey); + } catch (error: any) { + if (error instanceof AgentError) { + throw error; + } + throw new AgentError(`Failed to fetch servers: ${error.message}`); + } + } + + /** + * Create default server configuration for local development + */ + static createDefault(): string { + const defaultConfig: ApiResponse = { + succeeded: true, + code: 200000000, + message: "success", + data: { + servers: [ + { + version: "2025-01-09", + serverId: "1877234567890123456", + name: "local-dev-server", + transport: "http_sse", + config: { + url: "http://127.0.0.1:3001/rpc", + headers: {} + }, + connectTimeout: 5000, + requestTimeout: 30000 + }, + { + version: "2025-01-09", + serverId: "1877234567890123457", + name: "local-dev-server2", + transport: "http_sse", + config: { + url: "http://127.0.0.1:3002/rpc", + headers: {} + }, + connectTimeout: 5000, + requestTimeout: 30000 + } + ] + } + }; + + return JSON.stringify(defaultConfig); + } +} + +// Keep ServerConfigHelper as an alias for backward compatibility +export { ServerConfig as ServerConfigHelper }; diff --git a/third_party/gopher-orch/sdk/typescript/src/ffi.ts b/third_party/gopher-orch/sdk/typescript/src/ffi.ts new file mode 100644 index 00000000..139b47c1 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/src/ffi.ts @@ -0,0 +1,383 @@ +/** + * @file ffi.ts + * @brief Real FFI interface to gopher-orch C++ library using koffi + */ + +import { existsSync } from "node:fs"; +import koffi from "koffi"; +import { arch, platform } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// ESM equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Library configuration for different platforms and architectures +const LIBRARY_CONFIG = { + darwin: { + x64: { + name: "libgopher-orch.dylib", + searchPaths: [ + // Development build path (relative to this file) + join(__dirname, "../../../build/lib/libgopher-orch.dylib"), + join(__dirname, "../../../../build/lib/libgopher-orch.dylib"), + // Example local lib + join(__dirname, "../../examples/sdk/typescript/lib/libgopher-orch.dylib"), + // System installation paths + "/usr/local/lib/libgopher-orch.dylib", + "/opt/homebrew/lib/libgopher-orch.dylib", + "/usr/lib/libgopher-orch.dylib", + ], + }, + arm64: { + name: "libgopher-orch.dylib", + searchPaths: [ + // Development build path (relative to this file) + join(__dirname, "../../../build/lib/libgopher-orch.dylib"), + join(__dirname, "../../../../build/lib/libgopher-orch.dylib"), + // Example local lib + join(__dirname, "../../examples/sdk/typescript/lib/libgopher-orch.dylib"), + // System installation paths + "/usr/local/lib/libgopher-orch.dylib", + "/opt/homebrew/lib/libgopher-orch.dylib", + "/usr/lib/libgopher-orch.dylib", + ], + }, + }, + linux: { + x64: { + name: "libgopher-orch.so", + searchPaths: [ + join(__dirname, "../../../build/lib/libgopher-orch.so"), + join(__dirname, "../../../../build/lib/libgopher-orch.so"), + join(__dirname, "../../examples/sdk/typescript/lib/libgopher-orch.so"), + "/usr/local/lib/libgopher-orch.so", + "/usr/lib/x86_64-linux-gnu/libgopher-orch.so", + "/usr/lib64/libgopher-orch.so", + "/usr/lib/libgopher-orch.so", + ], + }, + arm64: { + name: "libgopher-orch.so", + searchPaths: [ + join(__dirname, "../../../build/lib/libgopher-orch.so"), + join(__dirname, "../../../../build/lib/libgopher-orch.so"), + join(__dirname, "../../examples/sdk/typescript/lib/libgopher-orch.so"), + "/usr/local/lib/libgopher-orch.so", + "/usr/lib/aarch64-linux-gnu/libgopher-orch.so", + "/usr/lib64/libgopher-orch.so", + "/usr/lib/libgopher-orch.so", + ], + }, + }, + win32: { + x64: { + name: "gopher-orch.dll", + searchPaths: [ + join(__dirname, "../../../build/lib/gopher-orch.dll"), + join(__dirname, "../../../../build/lib/gopher-orch.dll"), + "C:\\Program Files\\gopher-orch\\bin\\gopher-orch.dll", + "C:\\Program Files\\gopher-orch\\lib\\gopher-orch.dll", + ], + }, + }, +} as const; + +function getLibraryPath(): string { + // Check for environment variable override first + const envPath = process.env["GOPHER_ORCH_LIBRARY_PATH"]; + if (envPath && existsSync(envPath)) { + return envPath; + } + + const currentPlatform = platform() as keyof typeof LIBRARY_CONFIG; + const currentArch = arch() as keyof (typeof LIBRARY_CONFIG)[typeof currentPlatform]; + + if (!LIBRARY_CONFIG[currentPlatform] || !LIBRARY_CONFIG[currentPlatform][currentArch]) { + throw new Error(`Unsupported platform: ${currentPlatform} ${currentArch}`); + } + + const config = LIBRARY_CONFIG[currentPlatform][currentArch]; + + // Search through the paths to find the first one that exists + for (const searchPath of config.searchPaths) { + if (existsSync(searchPath)) { + return searchPath; + } + } + + // If no path found, throw an error with helpful information + const searchedPaths = config.searchPaths.join(", "); + throw new Error( + `Gopher-Orch library not found. Searched paths: ${searchedPaths}\n` + + `Please ensure the library is built and available at one of these locations.\n` + + `You can set GOPHER_ORCH_LIBRARY_PATH environment variable to the library path.` + ); +} + +// Gopher-Orch Library interface - using real C API functions +export let gopherOrchLib: any = {}; + +// Define the error info struct type for koffi +let ErrorInfoType: any = null; + +try { + const libPath = getLibraryPath(); + + // Load the shared library + const library = koffi.load(libPath); + + // Define the error_info struct to match C: + // typedef struct { + // gopher_orch_error_t code; // int + // const char* message; + // const char* details; + // const char* file; + // int32_t line; + // } gopher_orch_error_info_t; + ErrorInfoType = koffi.struct('gopher_orch_error_info_t', { + code: 'int', + message: 'const char*', + details: 'const char*', + file: 'const char*', + line: 'int32_t' + }); + + // Try to bind functions and see which ones are available + const availableFunctions: any = {}; + + // List of C API functions from orch_ffi.h + const functionList = [ + // Core initialization functions + { name: "gopher_orch_init", signature: "int", args: [] }, + { name: "gopher_orch_shutdown", signature: "void", args: [] }, + { name: "gopher_orch_is_initialized", signature: "int", args: [] }, + { name: "gopher_orch_last_error", signature: "gopher_orch_error_info_t*", args: [] }, + { name: "gopher_orch_clear_error", signature: "void", args: [] }, + { name: "gopher_orch_free", signature: "void", args: ["void*"] }, + + // Agent functions + { + name: "gopher_orch_agent_create_by_json", + signature: "void*", // gopher_orch_agent_t + args: ["string", "string", "string"], // provider, model, server_json_config + }, + { + name: "gopher_orch_agent_create_by_api_key", + signature: "void*", // gopher_orch_agent_t + args: ["string", "string", "string"], // provider, model, api_key + }, + { + name: "gopher_orch_agent_run", + signature: "string", // Returns string (OWNED - caller must gopher_orch_free) + args: ["void*", "string", "uint64_t"], // agent, query, timeout_ms + }, + { name: "gopher_orch_agent_add_ref", signature: "void", args: ["void*"] }, + { name: "gopher_orch_agent_release", signature: "void", args: ["void*"] }, + + // API functions + { + name: "gopher_orch_api_fetch_servers", + signature: "string", // Returns JSON string (OWNED - caller must gopher_orch_free) + args: ["string"], // api_key + }, + ]; + + // Try to bind each function + for (const func of functionList) { + try { + availableFunctions[func.name] = library.func(func.name, func.signature, func.args); + } catch (error: any) { + // Function not available - that's OK, we'll provide a fallback + } + } + + gopherOrchLib = availableFunctions; + +} catch (error) { + console.error(`Failed to load Gopher-Orch library: ${error}`); + // Continue with empty lib - fallbacks will be used + gopherOrchLib = {}; +} + +// FFI interface with fallbacks +export const library = { + gopher_orch_init: () => { + if (gopherOrchLib.gopher_orch_init) { + return gopherOrchLib.gopher_orch_init(); + } + return 0; // Success fallback + }, + + gopher_orch_shutdown: () => { + if (gopherOrchLib.gopher_orch_shutdown) { + gopherOrchLib.gopher_orch_shutdown(); + } + }, + + gopher_orch_is_initialized: () => { + if (gopherOrchLib.gopher_orch_is_initialized) { + return gopherOrchLib.gopher_orch_is_initialized() !== 0; + } + return true; // Fallback + }, + + gopher_orch_last_error: (): string | null => { + if (gopherOrchLib.gopher_orch_last_error && ErrorInfoType) { + try { + const errorPtr = gopherOrchLib.gopher_orch_last_error(); + if (errorPtr) { + // Decode the struct from the pointer + const errorInfo = koffi.decode(errorPtr, ErrorInfoType); + if (errorInfo && errorInfo.message) { + return errorInfo.message; + } + } + } catch (e: any) { + // Silently fail - will return null + } + } + return null; + }, + + gopher_orch_clear_error: () => { + if (gopherOrchLib.gopher_orch_clear_error) { + gopherOrchLib.gopher_orch_clear_error(); + } + }, + + gopher_orch_free: (ptr: any) => { + if (gopherOrchLib.gopher_orch_free && ptr) { + gopherOrchLib.gopher_orch_free(ptr); + } + }, + + gopher_orch_agent_create_by_json: (provider: string, model: string, serverJson: string) => { + if (gopherOrchLib.gopher_orch_agent_create_by_json) { + // Configure environment for HTTP/HTTPS handling + const originalEnv = { + CURL_CA_BUNDLE: process.env.CURL_CA_BUNDLE, + SSL_VERIFY_PEER: process.env.SSL_VERIFY_PEER, + SSL_VERIFY_HOST: process.env.SSL_VERIFY_HOST + }; + + // For HTTP URLs, disable SSL verification + process.env.SSL_VERIFY_PEER = "0"; + process.env.SSL_VERIFY_HOST = "0"; + process.env.CURL_CA_BUNDLE = ""; + + try { + const handle = gopherOrchLib.gopher_orch_agent_create_by_json(provider, model, serverJson); + return handle ? { handle, isNull: () => !handle } : null; + } catch (ffiError: any) { + console.error('FFI Error in agent creation:', ffiError.message); + return null; + } finally { + // Restore original environment + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + } + // FFI function not available, use fallback + console.warn('FFI function gopher_orch_agent_create_by_json not available, using fallback'); + return { handle: 'mock-agent-json', isNull: () => false }; + }, + + gopher_orch_agent_create_by_api_key: (provider: string, model: string, apiKey: string) => { + if (gopherOrchLib.gopher_orch_agent_create_by_api_key) { + // Configure environment for HTTP/HTTPS handling (same as JSON version) + const originalEnv = { + CURL_CA_BUNDLE: process.env.CURL_CA_BUNDLE, + SSL_VERIFY_PEER: process.env.SSL_VERIFY_PEER, + SSL_VERIFY_HOST: process.env.SSL_VERIFY_HOST + }; + + // For HTTP URLs, disable SSL verification + process.env.SSL_VERIFY_PEER = "0"; + process.env.SSL_VERIFY_HOST = "0"; + process.env.CURL_CA_BUNDLE = ""; + + try { + const handle = gopherOrchLib.gopher_orch_agent_create_by_api_key(provider, model, apiKey); + return handle ? { handle, isNull: () => !handle } : null; + } finally { + // Restore original environment + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + } + // Fallback + return { handle: 'mock-agent-api', isNull: () => false }; + }, + + gopher_orch_agent_release: (agent: any) => { + if (gopherOrchLib.gopher_orch_agent_release && agent?.handle) { + gopherOrchLib.gopher_orch_agent_release(agent.handle); + } + }, + + gopher_orch_agent_run: (agent: any, query: string, timeoutMs: number = 30000) => { + if (gopherOrchLib.gopher_orch_agent_run && agent?.handle) { + const result = gopherOrchLib.gopher_orch_agent_run(agent.handle, query, timeoutMs); + return result || `No response for query: "${query}"`; + } + // Fallback + return `[FFI Fallback] Agent processed query: "${query}" - Real gopher-orch library not available`; + }, + + gopher_orch_api_fetch_servers: (apiKey: string) => { + if (gopherOrchLib.gopher_orch_api_fetch_servers) { + return gopherOrchLib.gopher_orch_api_fetch_servers(apiKey); + } + // Fallback + return JSON.stringify({ + succeeded: true, + code: 200000000, + message: "success - fallback", + data: { + servers: [ + { + version: "2025-01-09", + serverId: "1877234567890123456", + name: "fallback-server", + transport: "http_sse", + config: { + url: "http://127.0.0.1:3001/rpc", + headers: {} + }, + connectTimeout: 5000, + requestTimeout: 30000 + } + ] + } + }); + } +}; + +// Helper functions +export function initializeLibrary(): boolean { + return true; +} + +export function shutdownLibrary(): void { + // Silent shutdown +} + +export function getLastError(): string | null { + return null; +} + +export function clearError(): void { + // No-op +} \ No newline at end of file diff --git a/third_party/gopher-orch/sdk/typescript/src/ffi_bridge_old.js b/third_party/gopher-orch/sdk/typescript/src/ffi_bridge_old.js new file mode 100644 index 00000000..a638405f --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/src/ffi_bridge_old.js @@ -0,0 +1,225 @@ +/** + * @file ffi_bridge.js + * @brief Native Node.js addon bridge to gopher-orch C++ library + * + * This creates a simple bridge using Node.js addon capabilities + * to call the real C++ FFI functions directly. + */ + +const path = require('path'); +const fs = require('fs'); + +// Find the gopher-orch shared library +function findGopherOrchLibrary() { + const possiblePaths = [ + '../lib/libgopher-orch.dylib', // From SDK dist, look in examples lib + '../../examples/sdk/typescript/lib/libgopher-orch.dylib', // From SDK root + '../../../build/lib/libgopher-orch.dylib', // From examples, look in build + './lib/libgopher-orch.dylib' // Local lib directory + ]; + + for (const libPath of possiblePaths) { + const fullPath = path.resolve(__dirname, libPath); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + + throw new Error('Could not find libgopher-orch library. Build the project first.'); +} + +// Simple FFI bridge using Node.js addon pattern +class FFIBridge { + constructor() { + this.libPath = findGopherOrchLibrary(); + this.nativeAddon = null; + this.loadNativeAddon(); + } + + loadNativeAddon() { + try { + console.log(`🔗 Loading FFI bridge from: ${this.libPath}`); + + // Try to use the real FFI executor first (calls actual C++ binary) + try { + const { getRealFFIExecutor } = require('./real_ffi_executor.js'); + const realFFI = getRealFFIExecutor(); + + // Wrap real FFI calls with proper error handling + this.nativeAddon = { + gopher_orch_init: () => realFFI.init(), + gopher_orch_shutdown: () => realFFI.shutdown(), + gopher_orch_agent_create_by_json: (provider, model, serverJson) => { + try { + const handle = nativeFFI.createAgentByJson(provider, model, serverJson); + // Wrap the native handle to provide isNull() method + return { + nativeHandle: handle, + provider: provider, + model: model, + isNull: () => handle === null || handle === undefined + }; + } catch (error) { + console.error('Failed to create agent by JSON:', error.message); + return { nativeHandle: null, isNull: () => true }; + } + }, + gopher_orch_agent_create_by_api_key: (provider, model, apiKey) => { + try { + const handle = nativeFFI.createAgentByApiKey(provider, model, apiKey); + return { + nativeHandle: handle, + provider: provider, + model: model, + apiKey: apiKey, + isNull: () => handle === null || handle === undefined + }; + } catch (error) { + console.error('Failed to create agent by API key:', error.message); + return { nativeHandle: null, isNull: () => true }; + } + }, + gopher_orch_agent_run: (agent, query, timeoutMs) => { + try { + if (!agent || !agent.nativeHandle) { + throw new Error('Invalid agent handle'); + } + return nativeFFI.runAgent(agent.nativeHandle, query, timeoutMs); + } catch (error) { + console.error('Failed to run agent:', error.message); + return `❌ Agent execution failed: ${error.message}\n\nQuery: "${query}"\nThis indicates an issue with the native FFI bridge or C++ library.`; + } + }, + gopher_orch_agent_release: (agent) => { + try { + if (agent && agent.nativeHandle) { + nativeFFI.releaseAgent(agent.nativeHandle); + console.log(`🗑️ Released native agent handle`); + } + } catch (error) { + console.error('Failed to release agent:', error.message); + } + }, + gopher_orch_api_fetch_servers: (apiKey) => { + try { + return nativeFFI.fetchServers(apiKey); + } catch (error) { + console.error('Failed to fetch servers:', error.message); + // Return fallback configuration + return JSON.stringify({ + succeeded: false, + message: `API fetch failed: ${error.message}` + }); + } + } + }; + + console.log('✅ Native FFI bridge loaded successfully'); + return; + + } catch (nativeError) { + console.warn('⚠️ Native FFI failed, using enhanced mock:', nativeError.message); + } + + // Fallback to enhanced mock implementation + this.nativeAddon = { + gopher_orch_init: () => 0, + gopher_orch_shutdown: () => {}, + gopher_orch_agent_create_by_json: (provider, model, serverJson) => { + return { + id: `enhanced_mock_${Date.now()}`, + provider: provider, + model: model, + isNull: () => false + }; + }, + gopher_orch_agent_create_by_api_key: (provider, model, apiKey) => { + return { + id: `enhanced_mock_api_${Date.now()}`, + provider: provider, + model: model, + apiKey: apiKey, + isNull: () => false + }; + }, + gopher_orch_agent_run: (agent, query, timeoutMs) => { + const duration = Math.floor(Math.random() * 2000) + 500; + return `🔄 Enhanced Mock Response (Native FFI unavailable) + +Query: "${query}" +Agent: ${agent.provider} ${agent.model} +Processing Time: ${duration}ms +Library: ${this.libPath} + +📝 This is an enhanced mock response because the native FFI bridge could not load. + To get real AI responses: + 1. Ensure the C++ library is properly compiled with FFI exports + 2. Set a valid ANTHROPIC_API_KEY + 3. Verify MCP servers are accessible + +Fallback Status: Enhanced mock operational, native FFI not available.`; + }, + gopher_orch_agent_release: (agent) => { + console.log(`🗑️ Released mock agent: ${agent.id}`); + }, + gopher_orch_api_fetch_servers: (apiKey) => { + return JSON.stringify({ + succeeded: true, + code: 200000000, + message: "success (mock)", + data: { + servers: [ + { + version: "2025-01-09", + serverId: "1877234567890123456", + name: "enhanced-mock-server", + transport: "http_sse", + config: { + url: "http://127.0.0.1:3001/rpc", + headers: {} + }, + connectTimeout: 5000, + requestTimeout: 30000 + } + ] + } + }); + } + }; + + console.log('✅ Enhanced mock FFI bridge loaded'); + + } catch (error) { + console.error('❌ Failed to load FFI bridge:', error.message); + this.nativeAddon = null; + } + } + + call(funcName, ...args) { + if (this.nativeAddon && this.nativeAddon[funcName]) { + try { + console.log(`📞 Bridge calling: ${funcName}`); + const result = this.nativeAddon[funcName](...args); + console.log(`✅ Bridge call completed: ${funcName}`); + return result; + } catch (error) { + console.error(`❌ Bridge call failed ${funcName}:`, error.message); + throw error; + } + } else { + throw new Error(`Function ${funcName} not available in FFI bridge`); + } + } +} + +// Export singleton instance +let bridgeInstance = null; + +function getBridge() { + if (!bridgeInstance) { + bridgeInstance = new FFIBridge(); + } + return bridgeInstance; +} + +module.exports = { getBridge }; \ No newline at end of file diff --git a/third_party/gopher-orch/sdk/typescript/src/index.ts b/third_party/gopher-orch/sdk/typescript/src/index.ts new file mode 100644 index 00000000..7d0a5607 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/src/index.ts @@ -0,0 +1,40 @@ +/** + * @file index.ts + * @brief Main entry point for gopher-orch TypeScript SDK + * + * @example + * ```typescript + * import { GopherAgent } from "gopher-orch-sdk"; + * + * // Initialize (optional - happens automatically on first create) + * GopherAgent.init(); + * + * // Create an agent + * const agent = GopherAgent.create({ + * provider: 'AnthropicProvider', + * model: 'claude-3-haiku-20240307', + * apiKey: 'your-api-key' + * }); + * + * // Run queries + * const answer = agent.run("What time is it?"); + * console.log(answer); + * ``` + */ + +// Main exports +export { GopherAgent, GopherAgentConfig, ServerConfig } from './agent.js'; + +// Backward compatibility exports +export { ReActAgent, ServerConfigHelper } from './agent.js'; + +// Type exports +export * from './types.js'; + +// Low-level exports (for advanced usage) +export { library, initializeLibrary, shutdownLibrary } from './ffi.js'; + +/** + * Library version information + */ +export const version = '1.0.0'; diff --git a/third_party/gopher-orch/sdk/typescript/src/native_ffi.js b/third_party/gopher-orch/sdk/typescript/src/native_ffi.js new file mode 100644 index 00000000..502515b6 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/src/native_ffi.js @@ -0,0 +1,188 @@ +/** + * @file native_ffi.js + * @brief Native FFI implementation using Node.js process.dlopen + * + * This loads the actual compiled C++ gopher-orch library and calls + * the real FFI functions directly, just like the C++ examples do. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +// Find the gopher-orch shared library +function findGopherOrchLibrary() { + const possiblePaths = [ + '../lib/libgopher-orch.dylib', // From SDK dist, look in examples lib + '../../examples/sdk/typescript/lib/libgopher-orch.dylib', // From SDK root + '../../../build/lib/libgopher-orch.dylib', // From examples, look in build + './lib/libgopher-orch.dylib' // Local lib directory + ]; + + for (const libPath of possiblePaths) { + const fullPath = path.resolve(__dirname, libPath); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + + throw new Error('Could not find libgopher-orch library. Build the project first.'); +} + +// Native FFI implementation using process.dlopen +class NativeFFI { + constructor() { + this.libPath = findGopherOrchLibrary(); + this.lib = null; + this.loadLibrary(); + } + + loadLibrary() { + try { + console.log(`🔗 Loading native library: ${this.libPath}`); + + // Check if process.dlopen is available + if (typeof process.dlopen !== 'function') { + throw new Error('process.dlopen not available in this Node.js version'); + } + + // Use Node.js process.dlopen to load the shared library + this.lib = {}; + + // Load the library with default flags + try { + process.dlopen(this.lib, this.libPath); + console.log('✅ process.dlopen successful'); + } catch (dlopenError) { + console.error('❌ process.dlopen failed:', dlopenError.message); + console.log('🔄 Attempting alternative loading method...'); + + // Alternative: try using require() if the library has a Node.js binding + try { + this.lib = require(this.libPath); + console.log('✅ Alternative require() loading successful'); + } catch (requireError) { + console.error('❌ Alternative loading also failed:', requireError.message); + throw new Error(`Both dlopen and require failed: ${dlopenError.message}`); + } + } + + // Verify the library was loaded + if (!this.lib || typeof this.lib !== 'object') { + throw new Error('Library loaded but no exports available'); + } + + console.log('✅ Native library loaded successfully'); + + // Debug: show what was actually loaded + const allKeys = Object.keys(this.lib); + console.log(`📋 Library exports (${allKeys.length} total):`); + console.log(` First 10: ${allKeys.slice(0, 10).join(', ')}${allKeys.length > 10 ? '...' : ''}`); + + // Verify that expected FFI functions are available + const expectedFunctions = [ + 'gopher_orch_init', + 'gopher_orch_shutdown', + 'gopher_orch_agent_create_by_json', + 'gopher_orch_agent_create_by_api_key', + 'gopher_orch_agent_run', + 'gopher_orch_agent_release', + 'gopher_orch_api_fetch_servers' + ]; + + const availableFunctions = []; + for (const funcName of expectedFunctions) { + if (this.lib[funcName] && typeof this.lib[funcName] === 'function') { + availableFunctions.push(funcName); + } + } + + console.log(`📋 Available FFI functions: ${availableFunctions.length}/${expectedFunctions.length}`); + if (availableFunctions.length > 0) { + console.log(` ✓ ${availableFunctions.join(', ')}`); + } + + if (availableFunctions.length === 0) { + console.warn('⚠️ No expected FFI functions found in library'); + console.log(' This likely means the library was built without FFI exports.'); + console.log(' Available functions:', allKeys.filter(k => typeof this.lib[k] === 'function').slice(0, 5).join(', ') + '...'); + } + + } catch (error) { + console.error('❌ Failed to load native library:', error.message); + this.lib = null; + } + } + + // Call a C++ FFI function with proper argument handling + callFunction(funcName, ...args) { + if (!this.lib) { + throw new Error('Native library not loaded'); + } + + if (!this.lib[funcName]) { + throw new Error(`Function ${funcName} not found in library`); + } + + try { + console.log(`🔧 Calling native function: ${funcName}(${args.map(a => typeof a).join(', ')})`); + + // Call the actual C++ FFI function + const result = this.lib[funcName](...args); + + console.log(`✅ Native function ${funcName} completed`); + return result; + + } catch (error) { + console.error(`❌ Error calling ${funcName}:`, error.message); + throw error; + } + } + + // Initialize the gopher-orch library + init() { + return this.callFunction('gopher_orch_init'); + } + + // Shutdown the gopher-orch library + shutdown() { + return this.callFunction('gopher_orch_shutdown'); + } + + // Create agent by JSON configuration + createAgentByJson(provider, model, serverJson) { + return this.callFunction('gopher_orch_agent_create_by_json', provider, model, serverJson); + } + + // Create agent by API key + createAgentByApiKey(provider, model, apiKey) { + return this.callFunction('gopher_orch_agent_create_by_api_key', provider, model, apiKey); + } + + // Run agent query + runAgent(agent, query, timeoutMs) { + return this.callFunction('gopher_orch_agent_run', agent, query, timeoutMs || 30000); + } + + // Release agent resources + releaseAgent(agent) { + return this.callFunction('gopher_orch_agent_release', agent); + } + + // Fetch MCP servers from API + fetchServers(apiKey) { + return this.callFunction('gopher_orch_api_fetch_servers', apiKey); + } +} + +// Export singleton instance +let nativeFFIInstance = null; + +function getNativeFFI() { + if (!nativeFFIInstance) { + nativeFFIInstance = new NativeFFI(); + } + return nativeFFIInstance; +} + +module.exports = { getNativeFFI }; \ No newline at end of file diff --git a/third_party/gopher-orch/sdk/typescript/src/real_ffi_executor.js b/third_party/gopher-orch/sdk/typescript/src/real_ffi_executor.js new file mode 100644 index 00000000..c69327a6 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/src/real_ffi_executor.js @@ -0,0 +1,197 @@ +/** + * @file real_ffi_executor.js + * @brief Real FFI executor using the working C++ binary + * + * This calls the actual compiled C++ client_example binary which has + * all the FFI functions working correctly and gets real AI responses. + */ + +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +// Find the C++ client example binary +function findClientExampleBinary() { + const possiblePaths = [ + '../../../build/bin/examples/sdk/client_example', + '../../../../build/bin/examples/sdk/client_example', + '../../../bin/client_example', + '../../../examples/sdk/client_example' + ]; + + for (const binaryPath of possiblePaths) { + const fullPath = path.resolve(__dirname, binaryPath); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + + throw new Error('Could not find client_example binary. Build the project first.'); +} + +// Real FFI executor using the C++ binary +class RealFFIExecutor { + constructor() { + this.binaryPath = findClientExampleBinary(); + console.log(`🎯 Using real C++ binary: ${this.binaryPath}`); + } + + // Initialize - just verify binary exists + init() { + if (!fs.existsSync(this.binaryPath)) { + throw new Error('C++ binary not available'); + } + return 0; + } + + // Shutdown - no-op for binary + shutdown() { + // Binary handles its own cleanup + } + + // Create agent by calling the binary with configuration + createAgentByJson(provider, model, serverJson) { + // For this approach, we just store the configuration + // and use it when running queries + return { + type: 'json', + provider, + model, + serverJson, + isNull: () => false + }; + } + + // Create agent by API key + createAgentByApiKey(provider, model, apiKey) { + return { + type: 'api_key', + provider, + model, + apiKey, + isNull: () => false + }; + } + + // Run agent query - this calls the real C++ binary + runAgent(agent, query, timeoutMs = 30000) { + console.log(`🚀 Executing real C++ agent for query: "${query}"`); + + try { + // Set up environment for the C++ binary + const env = { ...process.env }; + + // Use the agent's API key if available + if (agent.apiKey) { + env.ANTHROPIC_API_KEY = agent.apiKey; + } + + // Ensure we have an API key + if (!env.ANTHROPIC_API_KEY) { + throw new Error('ANTHROPIC_API_KEY not set'); + } + + // Set library paths + const libDir = path.resolve(__dirname, '../../../examples/sdk/typescript/lib'); + if (fs.existsSync(libDir)) { + env.DYLD_LIBRARY_PATH = `${libDir}:${env.DYLD_LIBRARY_PATH || ''}`; + env.LD_LIBRARY_PATH = `${libDir}:${env.LD_LIBRARY_PATH || ''}`; + } + + // Execute the real C++ binary with the query + const startTime = Date.now(); + const result = execSync(`"${this.binaryPath}" "${query}"`, { + env, + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 // 1MB buffer + }); + + const duration = Date.now() - startTime; + console.log(`✅ Real C++ agent completed in ${duration}ms`); + + // Parse the output to extract the actual agent response + const lines = result.split('\n'); + const responseStart = lines.findIndex(line => line.includes('Agent Response:') || line.includes('=== Agent Response ===')); + const responseEnd = lines.findIndex((line, index) => index > responseStart && (line.includes('===') || line.includes('Status:') || line.includes('Total execution time:'))); + + if (responseStart >= 0) { + let response = lines.slice(responseStart + 1, responseEnd >= 0 ? responseEnd : undefined).join('\n').trim(); + + // Clean up the response to extract just the AI answer + response = response.replace(/^-+$|^=+$/gm, '').trim(); + + if (response) { + return `🤖 Real AI Response (via C++ FFI):\n\n${response}\n\n⚡ Powered by: ${agent.provider} ${agent.model}\n🔗 Execution: C++ gopher-orch library\n⏱️ Duration: ${duration}ms`; + } + } + + // If we can't parse a specific response, return the relevant output + const relevantOutput = result.split('\n') + .filter(line => !line.includes('Loading MCP server') && + !line.includes('Configuration loaded') && + !line.includes('Available tools:') && + !line.includes('Creating agent') && + line.trim().length > 0) + .join('\n') + .trim(); + + return `🤖 Real AI Response (via C++ FFI):\n\n${relevantOutput}\n\n⚡ Powered by: ${agent.provider} ${agent.model}\n🔗 Execution: C++ gopher-orch library\n⏱️ Duration: ${duration}ms`; + + } catch (error) { + if (error.status === 124 || error.signal === 'SIGTERM') { + throw new Error(`Query execution timed out after ${timeoutMs}ms`); + } else if (error.stdout && error.stdout.includes('Agent error:')) { + // Extract the actual error message + const errorMatch = error.stdout.match(/Agent error: (.+)/); + const errorMsg = errorMatch ? errorMatch[1] : error.message; + throw new Error(`Real agent error: ${errorMsg}`); + } + throw new Error(`Real agent execution failed: ${error.message}`); + } + } + + // Release agent - no-op for binary approach + releaseAgent(agent) { + console.log(`🗑️ Released agent: ${agent.type}_${agent.provider}_${agent.model}`); + } + + // Fetch servers using the binary (if available) + fetchServers(apiKey) { + // For now, return a basic configuration + // In the future, could call a specific API fetcher binary + return JSON.stringify({ + succeeded: true, + code: 200000000, + message: "success", + data: { + servers: [ + { + version: "2025-01-09", + serverId: "1877234567890123456", + name: "real-binary-server", + transport: "http_sse", + config: { + url: "http://127.0.0.1:3001/rpc", + headers: {} + }, + connectTimeout: 5000, + requestTimeout: 30000 + } + ] + } + }); + } +} + +// Export singleton instance +let executorInstance = null; + +function getRealFFIExecutor() { + if (!executorInstance) { + executorInstance = new RealFFIExecutor(); + } + return executorInstance; +} + +module.exports = { getRealFFIExecutor }; \ No newline at end of file diff --git a/third_party/gopher-orch/sdk/typescript/src/types.ts b/third_party/gopher-orch/sdk/typescript/src/types.ts new file mode 100644 index 00000000..6def22e3 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/src/types.ts @@ -0,0 +1,66 @@ +/** + * @file types.ts + * @brief TypeScript type definitions for gopher-orch SDK + */ + +export interface ServerConfig { + version: string; + serverId: string; + name: string; + transport: string; + config: { + url: string; + headers: Record; + }; + connectTimeout: number; + requestTimeout: number; +} + +export interface ApiResponse { + succeeded: boolean; + code: number; + message: string; + data: { + servers: ServerConfig[]; + }; +} + +export interface AgentConfig { + provider: string; + model: string; + systemPrompt?: string; + maxIterations?: number; + temperature?: number; +} + +export interface AgentResult { + response: string; + status: 'success' | 'error' | 'timeout'; + iterationCount?: number; + tokensUsed?: number; +} + +export class AgentError extends Error { + constructor(message: string, public code?: string) { + super(message); + this.name = 'AgentError'; + } +} + +export class ApiKeyError extends AgentError { + constructor(message: string = 'Invalid or missing API key') { + super(message, 'API_KEY_ERROR'); + } +} + +export class ConnectionError extends AgentError { + constructor(message: string = 'Failed to connect to MCP servers') { + super(message, 'CONNECTION_ERROR'); + } +} + +export class TimeoutError extends AgentError { + constructor(message: string = 'Agent execution timed out') { + super(message, 'TIMEOUT_ERROR'); + } +} \ No newline at end of file diff --git a/third_party/gopher-orch/sdk/typescript/tsconfig.json b/third_party/gopher-orch/sdk/typescript/tsconfig.json new file mode 100644 index 00000000..adef7b62 --- /dev/null +++ b/third_party/gopher-orch/sdk/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/third_party/gopher-orch/src/CMakeLists.txt b/third_party/gopher-orch/src/CMakeLists.txt new file mode 100644 index 00000000..c74ef443 --- /dev/null +++ b/third_party/gopher-orch/src/CMakeLists.txt @@ -0,0 +1,255 @@ +# gopher-orch source files + +# Core library sources (orch-specific extensions) +set(ORCH_CORE_SOURCES + orch/hello.cc +) + +# MCP Server sources (requires gopher-mcp) +# Only include when gopher-mcp is available +set(ORCH_MCP_SOURCES "") +if(NOT BUILD_WITHOUT_GOPHER_MCP) + set(ORCH_MCP_SOURCES + gopher/orch/server/mcp_server.cc + gopher/orch/server/rest_server.cc + gopher/orch/server/curl_http_client.cc + gopher/orch/server/gateway_server.cpp + ) +endif() + +# LLM Provider sources (requires gopher-mcp for HTTP client) +set(ORCH_LLM_SOURCES "") +if(NOT BUILD_WITHOUT_GOPHER_MCP) + set(ORCH_LLM_SOURCES + gopher/orch/llm/openai_provider.cc + gopher/orch/llm/anthropic_provider.cc + gopher/orch/llm/llm_factory.cc + gopher/orch/llm/llm_runnable.cc + ) +endif() + +# Agent sources (requires LLM providers) +set(ORCH_AGENT_SOURCES "") +if(NOT BUILD_WITHOUT_GOPHER_MCP) + set(ORCH_AGENT_SOURCES + gopher/orch/agent/agent.cc + gopher/orch/agent/api_engine.cc + gopher/orch/agent/config_loader.cc + gopher/orch/agent/tool_registry.cc + gopher/orch/agent/tool_runnable.cc + gopher/orch/agent/agent_runnable.cc + gopher/orch/agent/tools_fetcher.cpp + ) +endif() + +# FFI sources (provides C API for agents) +set(ORCH_FFI_SOURCES "") +if(NOT BUILD_WITHOUT_GOPHER_MCP) + set(ORCH_FFI_SOURCES + gopher/orch/ffi/orch_ffi_agent.cc + ) +endif() + +# Combine all sources +set(GOPHER_ORCH_SOURCES + ${ORCH_CORE_SOURCES} + ${ORCH_MCP_SOURCES} + ${ORCH_LLM_SOURCES} + ${ORCH_AGENT_SOURCES} + ${ORCH_FFI_SOURCES} +) + +# Build static library +if(BUILD_STATIC_LIBS) + add_library(gopher-orch-static STATIC ${GOPHER_ORCH_SOURCES}) + target_include_directories(gopher-orch-static PUBLIC + $ + $ + $ + $ + ) + + # Add include directories for gopher-mcp's dependencies (fmt, nlohmann_json, etc.) + # These are needed when including gopher-mcp headers that use these libraries + if(NOT BUILD_WITHOUT_GOPHER_MCP) + # These paths are set by gopher-mcp's FetchContent + if(TARGET fmt) + get_target_property(FMT_INCLUDE_DIR fmt INTERFACE_INCLUDE_DIRECTORIES) + if(FMT_INCLUDE_DIR) + target_include_directories(gopher-orch-static PUBLIC ${FMT_INCLUDE_DIR}) + endif() + endif() + if(TARGET nlohmann_json) + get_target_property(NLOHMANN_JSON_INCLUDE_DIR nlohmann_json INTERFACE_INCLUDE_DIRECTORIES) + if(NLOHMANN_JSON_INCLUDE_DIR) + target_include_directories(gopher-orch-static PUBLIC ${NLOHMANN_JSON_INCLUDE_DIR}) + endif() + endif() + endif() + + # Link dependencies + if(NOT BUILD_WITHOUT_GOPHER_MCP) + target_link_libraries(gopher-orch-static PUBLIC + ${GOPHER_MCP_LIBRARIES} + ${CURL_LIBRARIES} + Threads::Threads + ) + # Define GOPHER_ORCH_WITH_MCP to enable MCP-specific code + # MCP_USE_STD_OPTIONAL_VARIANT=0 ensures ABI compatibility with gopher-mcp library + # (gopher-mcp uses mcp::optional/variant, not std:: types) + target_compile_definitions(gopher-orch-static PUBLIC + GOPHER_ORCH_WITH_MCP + MCP_USE_STD_OPTIONAL_VARIANT=0 + ) + else() + target_link_libraries(gopher-orch-static PUBLIC + Threads::Threads + ) + endif() + + set_target_properties(gopher-orch-static PROPERTIES + OUTPUT_NAME gopher-orch + POSITION_INDEPENDENT_CODE ON + ) + + # Set the main library alias + add_library(gopher-orch ALIAS gopher-orch-static) + + # Installation + install(TARGETS gopher-orch-static + EXPORT gopher-orch-targets + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + RUNTIME DESTINATION bin + COMPONENT libraries + ) +endif() + +# Build shared library +if(BUILD_SHARED_LIBS) + add_library(gopher-orch-shared SHARED ${GOPHER_ORCH_SOURCES}) + target_include_directories(gopher-orch-shared PUBLIC + $ + $ + $ + $ + ) + + # Add include directories for gopher-mcp's dependencies (fmt, nlohmann_json, etc.) + if(NOT BUILD_WITHOUT_GOPHER_MCP) + if(TARGET fmt) + get_target_property(FMT_INCLUDE_DIR fmt INTERFACE_INCLUDE_DIRECTORIES) + if(FMT_INCLUDE_DIR) + target_include_directories(gopher-orch-shared PUBLIC ${FMT_INCLUDE_DIR}) + endif() + endif() + if(TARGET nlohmann_json) + get_target_property(NLOHMANN_JSON_INCLUDE_DIR nlohmann_json INTERFACE_INCLUDE_DIRECTORIES) + if(NLOHMANN_JSON_INCLUDE_DIR) + target_include_directories(gopher-orch-shared PUBLIC ${NLOHMANN_JSON_INCLUDE_DIR}) + endif() + endif() + endif() + + # Link dependencies + if(NOT BUILD_WITHOUT_GOPHER_MCP) + target_link_libraries(gopher-orch-shared PUBLIC + ${GOPHER_MCP_LIBRARIES} + ${CURL_LIBRARIES} + Threads::Threads + ) + # Define GOPHER_ORCH_WITH_MCP to enable MCP-specific code + # MCP_USE_STD_OPTIONAL_VARIANT=0 ensures ABI compatibility with gopher-mcp library + # (gopher-mcp uses mcp::optional/variant, not std:: types) + target_compile_definitions(gopher-orch-shared PUBLIC + GOPHER_ORCH_WITH_MCP + MCP_USE_STD_OPTIONAL_VARIANT=0 + ) + else() + target_link_libraries(gopher-orch-shared PUBLIC + Threads::Threads + ) + endif() + + set_target_properties(gopher-orch-shared PROPERTIES + OUTPUT_NAME gopher-orch + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR} + ) + + # If only building shared, set it as the main library + if(NOT BUILD_STATIC_LIBS) + add_library(gopher-orch ALIAS gopher-orch-shared) + endif() + + # Installation + install(TARGETS gopher-orch-shared + EXPORT gopher-orch-targets + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + RUNTIME DESTINATION bin + COMPONENT libraries + ) +endif() + +# Export targets only when not using submodule +# (When using submodule, gopher-mcp targets aren't installable) +if(NOT USE_SUBMODULE_GOPHER_MCP) + install(EXPORT gopher-orch-targets + FILE gopher-orch-targets.cmake + NAMESPACE gopher-orch:: + DESTINATION lib/cmake/gopher-orch + COMPONENT development + ) +endif() + +# ═══════════════════════════════════════════════════════════════════════════════ +# MCP Gateway Production Binary +# ═══════════════════════════════════════════════════════════════════════════════ +if(NOT BUILD_WITHOUT_GOPHER_MCP) + add_executable(mcp_gateway + gopher/orch/server/mcp_gateway_main.cpp + ) + + target_include_directories(mcp_gateway PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${GOPHER_MCP_INCLUDE_DIR} + ${CURL_INCLUDE_DIRS} + ) + + # Add include directories for gopher-mcp's dependencies + if(TARGET fmt) + get_target_property(FMT_INCLUDE_DIR fmt INTERFACE_INCLUDE_DIRECTORIES) + if(FMT_INCLUDE_DIR) + target_include_directories(mcp_gateway PRIVATE ${FMT_INCLUDE_DIR}) + endif() + endif() + if(TARGET nlohmann_json) + get_target_property(NLOHMANN_JSON_INCLUDE_DIR nlohmann_json INTERFACE_INCLUDE_DIRECTORIES) + if(NLOHMANN_JSON_INCLUDE_DIR) + target_include_directories(mcp_gateway PRIVATE ${NLOHMANN_JSON_INCLUDE_DIR}) + endif() + endif() + + target_link_libraries(mcp_gateway + gopher-orch + ${GOPHER_MCP_LIBRARIES} + ${CURL_LIBRARIES} + Threads::Threads + ) + + target_compile_definitions(mcp_gateway PRIVATE + GOPHER_ORCH_WITH_MCP + MCP_USE_STD_OPTIONAL_VARIANT=0 + ) + + set_target_properties(mcp_gateway PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + ) + + # Installation + install(TARGETS mcp_gateway + RUNTIME DESTINATION bin + COMPONENT runtime + ) +endif() diff --git a/third_party/gopher-orch/src/gopher/orch/agent/agent.cc b/third_party/gopher-orch/src/gopher/orch/agent/agent.cc new file mode 100644 index 00000000..40ecc49c --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/agent/agent.cc @@ -0,0 +1,751 @@ +// ReActAgent Implementation + +#include "gopher/orch/agent/agent.h" + +#include +#include +#include +#include +#include +#include +#include "mcp/event/libevent_dispatcher.h" +#include "gopher/orch/agent/tools_fetcher.h" +#include "gopher/orch/agent/api_engine.h" +#include "gopher/orch/llm/anthropic_provider.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; + +// ═══════════════════════════════════════════════════════════════════════════ +// IMPLEMENTATION +// ═══════════════════════════════════════════════════════════════════════════ + +class ReActAgent::Impl { + public: + LLMProviderPtr provider; + ToolRegistryPtr tools; + ToolExecutorPtr executor; + AgentConfig config; + AgentState state; + + // Callbacks + AgentCallback completion_callback; + StepCallback step_callback; + ToolApprovalCallback approval_callback; + + // Current dispatcher (set during run) + Dispatcher* dispatcher = nullptr; + + // Cancellation flag + std::atomic cancelled{false}; + + // Thread safety + mutable std::mutex mutex; + + Impl(LLMProviderPtr p, ToolRegistryPtr t, const AgentConfig& c) + : provider(std::move(p)), + tools(t ? t : makeToolRegistry()), + executor(makeToolExecutor(tools)), + config(c) {} + + // Build messages for LLM call + std::vector buildMessages() const { + std::vector messages; + + // Add system prompt if configured + if (!config.system_prompt.empty()) { + messages.push_back(Message::system(config.system_prompt)); + } + + // Add conversation history + for (const auto& msg : state.messages) { + messages.push_back(msg); + } + + return messages; + } + + // Get tool specs for LLM + std::vector getToolSpecs() const { + if (tools) { + return tools->getToolSpecs(); + } + return {}; + } + + // Record a step + void recordStep(const AgentStep& step) { + state.steps.push_back(step); + + // Update total usage + if (step.llm_usage.has_value()) { + state.total_usage.prompt_tokens += step.llm_usage->prompt_tokens; + state.total_usage.completion_tokens += step.llm_usage->completion_tokens; + state.total_usage.total_tokens += step.llm_usage->total_tokens; + } + + // Invoke step callback + if (step_callback) { + step_callback(step); + } + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// FACTORY METHODS +// ═══════════════════════════════════════════════════════════════════════════ + +ReActAgent::Ptr ReActAgent::create(LLMProviderPtr provider, + ToolRegistryPtr tools, + const AgentConfig& config) { + return Ptr(new ReActAgent(std::move(provider), std::move(tools), config)); +} + +ReActAgent::Ptr ReActAgent::create(LLMProviderPtr provider, + const AgentConfig& config) { + return create(std::move(provider), nullptr, config); +} + +ReActAgent::ReActAgent(LLMProviderPtr provider, + ToolRegistryPtr tools, + const AgentConfig& config) + : impl_(std::make_unique( + std::move(provider), std::move(tools), config)) {} + +ReActAgent::~ReActAgent() { + cancel(); + shutdownConnections(); +} + +void ReActAgent::shutdownConnections() { + // Shutdown MCP server connections if we own them + if (tools_fetcher_ && owned_dispatcher_) { + bool shutdown_complete = false; + tools_fetcher_->shutdown(*owned_dispatcher_, [&shutdown_complete]() { + shutdown_complete = true; + }); + + // Wait for shutdown to complete (with timeout to avoid hanging) + auto shutdown_start = std::chrono::steady_clock::now(); + auto shutdown_timeout = std::chrono::seconds(5); + + while (!shutdown_complete) { + owned_dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + if (std::chrono::steady_clock::now() - shutdown_start > shutdown_timeout) { + break; // Don't hang forever in destructor + } + } + } + + // Release resources + tools_fetcher_.reset(); + owned_dispatcher_.reset(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RUN METHODS +// ═══════════════════════════════════════════════════════════════════════════ + +void ReActAgent::run(const std::string& query, + Dispatcher& dispatcher, + AgentCallback callback) { + run(query, {}, dispatcher, std::move(callback)); +} + +void ReActAgent::run(const std::string& query, + const std::vector& context, + Dispatcher& dispatcher, + AgentCallback callback) { + // Check if already running + if (impl_->state.status == AgentStatus::RUNNING) { + dispatcher.post([callback = std::move(callback)]() { + callback(Result( + Error(AgentError::UNKNOWN, "Agent is already running"))); + }); + return; + } + + // Check provider + if (!impl_->provider) { + dispatcher.post([callback = std::move(callback)]() { + callback(Result( + Error(AgentError::NO_PROVIDER, "No LLM provider configured"))); + }); + return; + } + + // Initialize state + impl_->state = AgentState(); + impl_->state.status = AgentStatus::RUNNING; + impl_->state.start_time = std::chrono::steady_clock::now(); + impl_->cancelled = false; + + // Add context messages + for (const auto& msg : context) { + impl_->state.messages.push_back(msg); + } + + // Add user query + impl_->state.messages.push_back(Message::user(query)); + + // Store callback and dispatcher + impl_->completion_callback = std::move(callback); + impl_->dispatcher = &dispatcher; + + // Start the ReAct loop + executeLoop(dispatcher); +} + +void ReActAgent::cancel() { + impl_->cancelled = true; + + if (impl_->state.status == AgentStatus::RUNNING) { + impl_->state.status = AgentStatus::CANCELLED; + impl_->state.error = Error(AgentError::CANCELLED, "Agent cancelled"); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STATE ACCESS +// ═══════════════════════════════════════════════════════════════════════════ + +const AgentState& ReActAgent::state() const { return impl_->state; } + +bool ReActAgent::isRunning() const { + return impl_->state.status == AgentStatus::RUNNING; +} + +void ReActAgent::setStepCallback(StepCallback callback) { + impl_->step_callback = std::move(callback); +} + +void ReActAgent::setToolApprovalCallback(ToolApprovalCallback callback) { + impl_->approval_callback = std::move(callback); +} + +LLMProviderPtr ReActAgent::provider() const { return impl_->provider; } + +ToolRegistryPtr ReActAgent::tools() const { return impl_->tools; } + +const AgentConfig& ReActAgent::config() const { return impl_->config; } + +void ReActAgent::setConfig(const AgentConfig& config) { + if (impl_->state.status != AgentStatus::RUNNING) { + impl_->config = config; + } +} + +void ReActAgent::addTool(const std::string& name, + const std::string& description, + const JsonValue& parameters, + ToolFunction function) { + if (impl_->tools) { + impl_->tools->addTool(name, description, parameters, std::move(function)); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// INTERNAL EXECUTION +// ═══════════════════════════════════════════════════════════════════════════ + +void ReActAgent::executeLoop(Dispatcher& dispatcher) { + // Check cancellation + if (impl_->cancelled) { + completeRun(AgentStatus::CANCELLED, dispatcher); + return; + } + + // Check iteration limit + if (impl_->state.current_iteration >= impl_->config.max_iterations) { + impl_->state.error = + Error(AgentError::MAX_ITERATIONS, "Maximum iterations reached"); + completeRun(AgentStatus::MAX_ITERATIONS_REACHED, dispatcher); + return; + } + + // Check timeout + auto elapsed = std::chrono::steady_clock::now() - impl_->state.start_time; + if (elapsed > impl_->config.timeout) { + impl_->state.error = Error(AgentError::TIMEOUT, "Agent timeout"); + completeRun(AgentStatus::FAILED, dispatcher); + return; + } + + impl_->state.current_iteration++; + + // Call LLM + callLLM(dispatcher); +} + +void ReActAgent::callLLM(Dispatcher& dispatcher) { + auto messages = impl_->buildMessages(); + auto tools = impl_->getToolSpecs(); + auto& config = impl_->config.llm_config; + + auto start_time = std::chrono::steady_clock::now(); + + impl_->provider->chat( + messages, tools, config, dispatcher, + [this, &dispatcher, start_time](Result result) { + if (!mcp::holds_alternative(result)) { + impl_->state.error = mcp::get(result); + completeRun(AgentStatus::FAILED, dispatcher); + return; + } + + auto duration = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start_time); + + const auto& response = mcp::get(result); + + // Create step record + AgentStep step; + step.step_number = impl_->state.current_iteration; + step.llm_message = response.message; + step.llm_usage = response.usage; + step.llm_duration = duration; + + // Record step first (will be updated with tool results if needed) + impl_->recordStep(step); + + // Handle response (may complete run or execute tools) + handleLLMResponse(response, dispatcher); + }); +} + +void ReActAgent::handleLLMResponse(const LLMResponse& response, + Dispatcher& dispatcher) { + // Add assistant message to history + impl_->state.messages.push_back(response.message); + + // Check if LLM wants to call tools + if (response.hasToolCalls()) { + // Execute tool calls + executeToolCalls(response.toolCalls(), dispatcher); + } else { + // No tool calls - agent is done + completeRun(AgentStatus::COMPLETED, dispatcher); + } +} + +void ReActAgent::executeToolCalls(const std::vector& calls, + Dispatcher& dispatcher) { + // Check for tool approval + if (impl_->approval_callback) { + for (const auto& call : calls) { + if (!impl_->approval_callback(call)) { + // Tool call rejected + impl_->state.error = + Error(AgentError::CANCELLED, "Tool call rejected: " + call.name); + completeRun(AgentStatus::CANCELLED, dispatcher); + return; + } + } + } + + if (!impl_->executor) { + // No executor configured - add error result + for (const auto& call : calls) { + impl_->state.messages.push_back( + Message::toolResult(call.id, "Error: No tools configured")); + } + // Continue loop + dispatcher.post([this, &dispatcher]() { executeLoop(dispatcher); }); + return; + } + + // Execute tools via executor + auto start_time = std::chrono::steady_clock::now(); + + impl_->executor->executeToolCalls( + calls, impl_->config.parallel_tool_calls, dispatcher, + [this, &dispatcher, calls, + start_time](std::vector> results) { + auto duration = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start_time); + + handleToolResults(calls, results, dispatcher); + }); +} + +void ReActAgent::handleToolResults( + const std::vector& calls, + const std::vector>& results, + Dispatcher& dispatcher) { + // Update last step with tool executions + if (!impl_->state.steps.empty()) { + auto& last_step = impl_->state.steps.back(); + for (size_t i = 0; i < calls.size(); ++i) { + ToolExecution exec; + exec.tool_name = calls[i].name; + exec.call_id = calls[i].id; + exec.input = calls[i].arguments; + + if (i < results.size()) { + if (mcp::holds_alternative(results[i])) { + exec.output = mcp::get(results[i]); + exec.success = true; + } else { + exec.success = false; + exec.error_message = mcp::get(results[i]).message; + } + } + + last_step.tool_executions.push_back(std::move(exec)); + } + } + + // Add tool results to messages + for (size_t i = 0; i < calls.size(); ++i) { + std::string result_content; + + if (i < results.size()) { + if (mcp::holds_alternative(results[i])) { + result_content = mcp::get(results[i]).toString(); + } else { + result_content = "Error: " + mcp::get(results[i]).message; + } + } else { + result_content = "Error: No result returned"; + } + + impl_->state.messages.push_back( + Message::toolResult(calls[i].id, result_content)); + } + + // Continue the loop + dispatcher.post([this, &dispatcher]() { executeLoop(dispatcher); }); +} + +void ReActAgent::completeRun(AgentStatus status, Dispatcher& dispatcher) { + impl_->state.status = status; + impl_->state.elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - impl_->state.start_time); + + auto result = buildResult(); + + if (impl_->completion_callback) { + auto callback = std::move(impl_->completion_callback); + impl_->completion_callback = nullptr; + + if (status == AgentStatus::COMPLETED) { + callback(Result(std::move(result))); + } else { + callback(Result(impl_->state.error.value_or( + Error(AgentError::UNKNOWN, "Unknown error")))); + } + } +} + +AgentResult ReActAgent::buildResult() const { + AgentResult result; + result.status = impl_->state.status; + result.messages = impl_->state.messages; + result.steps = impl_->state.steps; + result.total_usage = impl_->state.total_usage; + result.duration = impl_->state.elapsed; + result.error = impl_->state.error; + + // Get final response from last assistant message + for (auto it = impl_->state.messages.rbegin(); + it != impl_->state.messages.rend(); ++it) { + if (it->role == Role::ASSISTANT && !it->content.empty()) { + result.response = it->content; + break; + } + } + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STATIC CONVENIENCE METHODS +// ═══════════════════════════════════════════════════════════════════════════ + +ReActAgent::Ptr ReActAgent::createByJson(const std::string& provider_name, + const std::string& model, + const std::string& server_json_config) { + using namespace gopher::orch::llm; + + // Validate API response format if it's an API response + try { + JsonValue json = JsonValue::parse(server_json_config); + + // Check if this is an API response format + if (json.contains("succeeded") && json.contains("message")) { + // Validate API response success + if (!json["succeeded"].getBool()) { + // API request failed - return nullptr (error will be handled by caller) + return nullptr; + } + + // Check message field for success status + std::string message = json["message"].getString(); + if (message != "success") { + // API response indicates failure - return nullptr + // The actual error message will be handled during tool loading + return nullptr; + } + } + } catch (const std::exception& e) { + // JSON parsing failed + return nullptr; + } + + // Create LLM provider + // Note: AnthropicProvider::create() will resolve API key from environment variable + // and throw std::runtime_error with helpful message if not set + LLMProviderPtr llm_provider; + if (provider_name == "AnthropicProvider" || provider_name == "anthropic") { + llm_provider = AnthropicProvider::create(""); + } else { + return nullptr; // Error: Unsupported provider + } + + if (!llm_provider) { + return nullptr; // Error: Failed to create provider + } + + // Load tools immediately from server config + auto dispatcher = std::make_unique("tools_loader"); + auto tools_fetcher = std::make_unique(); + + // Setup synchronization for async operations + bool load_complete = false; + bool load_success = false; + + // Load tools from config + tools_fetcher->loadFromJson(server_json_config, *dispatcher, + [&](VoidResult result) { + load_complete = true; + load_success = mcp::holds_alternative(result); + }); + + // Wait for loading to complete + auto load_start = std::chrono::steady_clock::now(); + auto load_timeout = std::chrono::seconds(15); + + while (!load_complete) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + if (std::chrono::steady_clock::now() - load_start > load_timeout) { + return nullptr; // Timeout loading tools + } + } + + if (!load_success) { + return nullptr; // Failed to load tools + } + + // Allow async operations to complete + for (int i = 0; i < 20; i++) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + // Get the loaded tool registry + auto registry = tools_fetcher->getRegistry(); + if (!registry) { + return nullptr; // Failed to get registry + } + + // NOTE: Do NOT shutdown tools_fetcher here! + // The SSE connections must remain open for tool execution. + // The connections will be closed when the agent is destroyed. + + // Create agent with basic config + AgentConfig agent_config(model); + agent_config.withSystemPrompt( + "You are a helpful assistant with access to various tools. " + "Use the appropriate tools to complete tasks. " + "Always explain your reasoning before taking action."); + agent_config.withMaxIterations(5); + agent_config.withTemperature(0.3); + + // Create agent with loaded tools + auto agent = ReActAgent::create(llm_provider, registry, agent_config); + + // Store tools_fetcher and dispatcher in agent to keep connections alive + if (agent) { + agent->server_json_config_ = server_json_config; + agent->tools_loaded_ = true; + agent->tools_fetcher_ = std::move(tools_fetcher); + agent->owned_dispatcher_ = std::move(dispatcher); + } + + return agent; +} + +ReActAgent::Ptr ReActAgent::createByApiKey(const std::string& provider_name, + const std::string& model, + const std::string& api_key) { + // Fetch server configuration from remote API + std::string server_json_config; + try { + server_json_config = ApiEngine::fetchMcpServers(api_key); + } catch (const std::exception& e) { + // Failed to fetch server config + return nullptr; + } + + // Use the fetched config to create agent + return createByJson(provider_name, model, server_json_config); +} + +std::string ReActAgent::run(const std::string& query) { + // Lazy load tools on first run + if (!tools_loaded_) { + // Validate API response first + try { + JsonValue json = JsonValue::parse(server_json_config_); + + // Check if this is an API response format + if (json.contains("succeeded") && json.contains("message")) { + // Validate API response success + if (!json["succeeded"].getBool()) { + std::string message = json.contains("message") ? json["message"].getString() : "API request failed"; + return "Error: " + message; + } + + // Check message field for success status + std::string message = json["message"].getString(); + if (message != "success") { + return "Error: " + message; + } + } + } catch (const std::exception& e) { + return "Error: Invalid JSON configuration - " + std::string(e.what()); + } + + if (!loadTools()) { + return "Error: Failed to load MCP tools"; + } + tools_loaded_ = true; + } + + // Use the existing static run method with loaded configuration + return runWithLoadedAgent(query); +} + + +bool ReActAgent::loadTools() { + // Create dispatcher for loading tools (will be kept alive for agent lifetime) + auto dispatcher = std::make_unique("tools_loader"); + + // Create tools fetcher and load tools (will be kept alive for agent lifetime) + auto tools_fetcher = std::make_unique(); + + // Setup synchronization for async operations + std::mutex mtx; + std::condition_variable cv; + bool load_complete = false; + bool load_success = false; + + // Load tools from config + tools_fetcher->loadFromJson(server_json_config_, *dispatcher, + [&](VoidResult result) { + std::lock_guard lock(mtx); + load_complete = true; + load_success = mcp::holds_alternative(result); + cv.notify_one(); + }); + + // Wait for loading to complete + auto load_start = std::chrono::steady_clock::now(); + auto load_timeout = std::chrono::seconds(10); + + while (!load_complete) { + dispatcher->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + if (std::chrono::steady_clock::now() - load_start > load_timeout) { + return false; // Timeout + } + } + + if (load_success) { + auto registry = tools_fetcher->getRegistry(); + if (registry) { + // Update the agent's tools + impl_->tools = registry; + impl_->executor = makeToolExecutor(registry); + + // Store tools_fetcher and dispatcher to keep MCP connections alive + tools_fetcher_ = std::move(tools_fetcher); + owned_dispatcher_ = std::move(dispatcher); + + return true; + } + } + + return false; +} + +std::string ReActAgent::runWithLoadedAgent(const std::string& query) { + // Use the owned_dispatcher_ if available (for MCP connections created by createByJson) + // Otherwise create a static dispatcher for standalone usage + Dispatcher* dispatcher_to_use = nullptr; + + static thread_local std::unique_ptr static_dispatcher; + + if (owned_dispatcher_) { + // Use the dispatcher that owns the MCP connections + dispatcher_to_use = owned_dispatcher_.get(); + } else { + // Initialize static dispatcher on first use (per thread) for standalone agents + if (!static_dispatcher) { + static_dispatcher = std::make_unique("query_executor"); + } + dispatcher_to_use = static_dispatcher.get(); + } + + // Setup synchronization for async operations + std::mutex mtx; + std::condition_variable cv; + bool agent_complete = false; + std::string final_response; + + // Run agent and wait for completion + run(query, *dispatcher_to_use, + [&](Result result) { + std::lock_guard lock(mtx); + agent_complete = true; + if (mcp::holds_alternative(result)) { + auto response = mcp::get(result); + final_response = response.response; + } else { + final_response = "Error: " + mcp::get(result).message; + } + cv.notify_one(); + }); + + // Wait for agent completion with proper event loop handling + auto agent_start = std::chrono::steady_clock::now(); + auto agent_timeout = std::chrono::seconds(60); + + while (!agent_complete) { + // Process events on the dispatcher that owns MCP connections + dispatcher_to_use->run(mcp::event::RunType::NonBlock); + + // Short sleep to prevent busy waiting + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + if (std::chrono::steady_clock::now() - agent_start > agent_timeout) { + // Cancel the agent before timing out + cancel(); + return "Error: Agent execution timed out after 60 seconds"; + } + } + + return final_response; +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/agent/agent_runnable.cc b/third_party/gopher-orch/src/gopher/orch/agent/agent_runnable.cc new file mode 100644 index 00000000..c022d0dc --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/agent/agent_runnable.cc @@ -0,0 +1,499 @@ +// AgentRunnable Implementation + +#include "gopher/orch/agent/agent_runnable.h" + +#include + +namespace gopher { +namespace orch { +namespace agent { + +// ============================================================================= +// Factory Methods +// ============================================================================= + +AgentRunnable::Ptr AgentRunnable::create(LLMProviderPtr provider, + ToolExecutorPtr executor, + const AgentConfig& config) { + return Ptr( + new AgentRunnable(std::move(provider), std::move(executor), config)); +} + +AgentRunnable::Ptr AgentRunnable::create(LLMProviderPtr provider, + ToolRegistryPtr registry, + const AgentConfig& config) { + ToolExecutorPtr executor = registry ? makeToolExecutor(registry) : nullptr; + return create(std::move(provider), std::move(executor), config); +} + +AgentRunnable::Ptr AgentRunnable::create(LLMProviderPtr provider, + const AgentConfig& config) { + return create(std::move(provider), ToolExecutorPtr{}, config); +} + +AgentRunnable::AgentRunnable(LLMProviderPtr provider, + ToolExecutorPtr executor, + const AgentConfig& config) + : provider_(std::move(provider)), + executor_(std::move(executor)), + config_(config) {} + +// ============================================================================= +// Runnable Interface +// ============================================================================= + +std::string AgentRunnable::name() const { return "AgentRunnable"; } + +void AgentRunnable::invoke(const JsonValue& input, + const RunnableConfig& /* runnable_config */, + Dispatcher& dispatcher, + Callback callback) { + // Validate provider + if (!provider_) { + postError(dispatcher, std::move(callback), + AgentError::NO_PROVIDER, "No LLM provider configured"); + return; + } + + // Parse input + auto parsed = parseInput(input); + + if (parsed.query.empty() && parsed.context.empty()) { + postError(dispatcher, std::move(callback), + OrchError::INVALID_ARGUMENT, + "No query or messages provided"); + return; + } + + // Initialize state + AgentState state; + state.status = AgentStatus::RUNNING; + state.start_time = std::chrono::steady_clock::now(); + state.remaining_steps = parsed.config.max_iterations; + + // Add context messages + for (const auto& msg : parsed.context) { + state.messages.push_back(msg); + } + + // Add user query as message if provided + if (!parsed.query.empty()) { + state.messages.push_back(Message::user(parsed.query)); + } + + // Store config for this run + config_ = parsed.config; + + // Start the ReAct loop + executeLoop(state, dispatcher, std::move(callback)); +} + +// ============================================================================= +// Input Parsing +// ============================================================================= + +AgentRunnable::ParsedInput AgentRunnable::parseInput( + const JsonValue& input) const { + ParsedInput result; + result.config = config_; // Start with current config + + // Handle string input as simple query + if (input.isString()) { + result.query = input.getString(); + return result; + } + + if (!input.isObject()) { + return result; + } + + // Parse query + if (input.contains("query") && input["query"].isString()) { + result.query = input["query"].getString(); + } + + // Parse context messages + if (input.contains("context") && input["context"].isArray()) { + const auto& context_arr = input["context"]; + for (size_t i = 0; i < context_arr.size(); ++i) { + const auto& msg_json = context_arr[i]; + if (!msg_json.isObject()) + continue; + + Role role = Role::USER; + if (msg_json.contains("role") && msg_json["role"].isString()) { + role = parseRole(msg_json["role"].getString()); + } + + std::string content; + if (msg_json.contains("content") && msg_json["content"].isString()) { + content = msg_json["content"].getString(); + } + + result.context.push_back(Message(role, content)); + } + } + + // Parse LangGraph-style messages input + if (input.contains("messages") && input["messages"].isArray()) { + const auto& msgs_arr = input["messages"]; + for (size_t i = 0; i < msgs_arr.size(); ++i) { + const auto& msg_json = msgs_arr[i]; + if (!msg_json.isObject()) + continue; + + Role role = Role::USER; + if (msg_json.contains("role") && msg_json["role"].isString()) { + role = parseRole(msg_json["role"].getString()); + } + + std::string content; + if (msg_json.contains("content") && msg_json["content"].isString()) { + content = msg_json["content"].getString(); + } + + result.context.push_back(Message(role, content)); + } + } + + // Parse config overrides + if (input.contains("config") && input["config"].isObject()) { + const auto& cfg = input["config"]; + + if (cfg.contains("max_iterations") && cfg["max_iterations"].isNumber()) { + result.config.max_iterations = cfg["max_iterations"].getInt(); + } + if (cfg.contains("system_prompt") && cfg["system_prompt"].isString()) { + result.config.system_prompt = cfg["system_prompt"].getString(); + } + if (cfg.contains("model") && cfg["model"].isString()) { + result.config.llm_config.model = cfg["model"].getString(); + } + if (cfg.contains("temperature") && cfg["temperature"].isNumber()) { + result.config.llm_config.temperature = cfg["temperature"].getFloat(); + } + } + + return result; +} + +// ============================================================================= +// Agent Loop Execution +// ============================================================================= + +void AgentRunnable::executeLoop(AgentState& state, + Dispatcher& dispatcher, + Callback callback) { + // Check if should continue + if (!shouldContinue(state)) { + completeRun(state, std::move(callback)); + return; + } + + state.current_iteration++; + state.remaining_steps--; + + // Call LLM + callLLM(state, dispatcher, std::move(callback)); +} + +void AgentRunnable::callLLM(AgentState& state, + Dispatcher& dispatcher, + Callback callback) { + auto messages = buildMessages(state); + auto tools = getToolSpecs(); + + auto start_time = std::chrono::steady_clock::now(); + + // Capture state by value for the async callback + provider_->chat( + messages, tools, config_.llm_config, dispatcher, + [this, state, start_time, &dispatcher, + callback = std::move(callback)](Result result) mutable { + if (mcp::holds_alternative(result)) { + state.status = AgentStatus::FAILED; + state.error = mcp::get(result); + completeRun(state, std::move(callback)); + return; + } + + auto duration = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start_time); + + const auto& response = mcp::get(result); + + // Record step + recordStep(state, response.message, response.usage, duration); + + // Handle response + handleLLMResponse(response, state, dispatcher, std::move(callback)); + }); +} + +void AgentRunnable::handleLLMResponse(const LLMResponse& response, + AgentState& state, + Dispatcher& dispatcher, + Callback callback) { + // Add assistant message to history + state.messages.push_back(response.message); + + // Update usage + if (response.usage.has_value()) { + state.total_usage.prompt_tokens += response.usage->prompt_tokens; + state.total_usage.completion_tokens += response.usage->completion_tokens; + state.total_usage.total_tokens += response.usage->total_tokens; + } + + // Check if LLM wants to call tools + if (response.hasToolCalls()) { + executeTools(response.toolCalls(), state, dispatcher, std::move(callback)); + } else { + // No tool calls - agent is done + state.status = AgentStatus::COMPLETED; + completeRun(state, std::move(callback)); + } +} + +void AgentRunnable::executeTools(const std::vector& calls, + AgentState& state, + Dispatcher& dispatcher, + Callback callback) { + // Check tool approval + if (approval_callback_) { + for (const auto& call : calls) { + if (!approval_callback_(call)) { + state.status = AgentStatus::CANCELLED; + state.error = + Error(AgentError::CANCELLED, "Tool call rejected: " + call.name); + completeRun(state, std::move(callback)); + return; + } + } + } + + // Check if we have an executor + if (!executor_) { + // No tools - add error messages and continue + for (const auto& call : calls) { + state.messages.push_back( + Message::toolResult(call.id, "Error: No tools configured")); + } + // Continue loop to let LLM handle the error + dispatcher.post( + [this, state, &dispatcher, callback = std::move(callback)]() mutable { + executeLoop(state, dispatcher, std::move(callback)); + }); + return; + } + + // Execute tools + executor_->executeToolCalls( + calls, config_.parallel_tool_calls, dispatcher, + [this, calls, state, &dispatcher, callback = std::move(callback)]( + std::vector> results) mutable { + // Update last step with tool executions + if (!state.steps.empty()) { + auto& last_step = state.steps.back(); + for (size_t i = 0; i < calls.size(); ++i) { + ToolExecution exec; + exec.tool_name = calls[i].name; + exec.call_id = calls[i].id; + exec.input = calls[i].arguments; + + if (i < results.size()) { + if (mcp::holds_alternative(results[i])) { + exec.output = mcp::get(results[i]); + exec.success = true; + } else { + exec.success = false; + exec.error_message = mcp::get(results[i]).message; + } + } + + last_step.tool_executions.push_back(std::move(exec)); + } + } + + // Add tool results to messages + for (size_t i = 0; i < calls.size(); ++i) { + std::string result_content; + + if (i < results.size()) { + if (mcp::holds_alternative(results[i])) { + result_content = mcp::get(results[i]).toString(); + } else { + result_content = "Error: " + mcp::get(results[i]).message; + } + } else { + result_content = "Error: No result returned"; + } + + state.messages.push_back( + Message::toolResult(calls[i].id, result_content)); + } + + // Continue the loop + executeLoop(state, dispatcher, std::move(callback)); + }); +} + +void AgentRunnable::completeRun(AgentState& state, Callback callback) { + state.elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - state.start_time); + + // Check for max iterations + if (state.remaining_steps <= 0 && state.status == AgentStatus::RUNNING) { + state.status = AgentStatus::MAX_ITERATIONS_REACHED; + state.error = + Error(AgentError::MAX_ITERATIONS, "Maximum iterations reached"); + } + + // Build output + if (state.status == AgentStatus::COMPLETED || + state.status == AgentStatus::MAX_ITERATIONS_REACHED) { + JsonValue output = buildOutput(state); + callback(Result(std::move(output))); + } else { + // Return error + callback(Result( + state.error.value_or(Error(AgentError::UNKNOWN, "Unknown error")))); + } +} + +// ============================================================================= +// Output Building +// ============================================================================= + +JsonValue AgentRunnable::buildOutput(const AgentState& state) const { + JsonValue output = JsonValue::object(); + + // Get final response from last assistant message + std::string response; + for (auto it = state.messages.rbegin(); it != state.messages.rend(); ++it) { + if (it->role == Role::ASSISTANT && !it->content.empty()) { + response = it->content; + break; + } + } + output["response"] = response; + + // Status + output["status"] = agentStatusToString(state.status); + + // Iterations + output["iterations"] = static_cast(state.steps.size()); + + // Messages + JsonValue messages_arr = JsonValue::array(); + for (const auto& msg : state.messages) { + JsonValue msg_json = JsonValue::object(); + msg_json["role"] = roleToString(msg.role); + msg_json["content"] = msg.content; + if (msg.tool_call_id.has_value()) { + msg_json["tool_call_id"] = *msg.tool_call_id; + } + if (msg.hasToolCalls()) { + JsonValue calls_arr = JsonValue::array(); + for (const auto& call : *msg.tool_calls) { + JsonValue call_json = JsonValue::object(); + call_json["id"] = call.id; + call_json["name"] = call.name; + call_json["arguments"] = call.arguments; + calls_arr.push_back(call_json); + } + msg_json["tool_calls"] = calls_arr; + } + messages_arr.push_back(msg_json); + } + output["messages"] = messages_arr; + + // Usage + JsonValue usage = JsonValue::object(); + usage["prompt_tokens"] = state.total_usage.prompt_tokens; + usage["completion_tokens"] = state.total_usage.completion_tokens; + usage["total_tokens"] = state.total_usage.total_tokens; + output["usage"] = usage; + + // Duration + output["duration_ms"] = static_cast(state.elapsed.count()); + + // Error if any + if (state.error.has_value()) { + JsonValue error = JsonValue::object(); + error["code"] = state.error->code; + error["message"] = state.error->message; + output["error"] = error; + } + + return output; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +std::vector AgentRunnable::buildMessages( + const AgentState& state) const { + std::vector messages; + + // Add system prompt if configured + if (!config_.system_prompt.empty()) { + messages.push_back(Message::system(config_.system_prompt)); + } + + // Add conversation history + for (const auto& msg : state.messages) { + messages.push_back(msg); + } + + return messages; +} + +std::vector AgentRunnable::getToolSpecs() const { + if (executor_ && executor_->registry()) { + return executor_->registry()->getToolSpecs(); + } + return {}; +} + +bool AgentRunnable::shouldContinue(const AgentState& state) const { + // Stop if not running + if (state.status != AgentStatus::RUNNING) { + return false; + } + + // Stop if max iterations reached + if (state.remaining_steps <= 0) { + return false; + } + + // Check timeout + auto elapsed = std::chrono::steady_clock::now() - state.start_time; + if (elapsed > config_.timeout) { + return false; + } + + return true; +} + +void AgentRunnable::recordStep(AgentState& state, + const Message& llm_message, + const optional& usage, + std::chrono::milliseconds llm_duration) { + AgentStep step; + step.step_number = state.current_iteration; + step.llm_message = llm_message; + step.llm_usage = usage; + step.llm_duration = llm_duration; + + state.steps.push_back(std::move(step)); + + // Invoke step callback + if (step_callback_ && config_.enable_step_callbacks) { + step_callback_(state.steps.back()); + } +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/agent/api_engine.cc b/third_party/gopher-orch/src/gopher/orch/agent/api_engine.cc new file mode 100644 index 00000000..43916f67 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/agent/api_engine.cc @@ -0,0 +1,34 @@ +#include "gopher/orch/agent/api_engine.h" + +#include + +#include "gopher/orch/server/rest_server.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; +using namespace gopher::orch::server; + +std::string ApiEngine::fetchMcpServers(const std::string& apiKey) { + // Validate API key + if (apiKey.empty()) { + throw std::runtime_error("API key is required"); + } + + // Construct the URL + std::string url = getApiUrlRoot() + "/v1/mcp-servers"; + + // Set up headers + std::map headers; + headers["Content-Type"] = "application/json"; + headers["X-API-Key"] = apiKey; + + // Make the HTTP request using the utility function + return fetchJsonSync(url, headers); +} + +} // namespace agent +} // namespace orch +} // namespace gopher \ No newline at end of file diff --git a/third_party/gopher-orch/src/gopher/orch/agent/config_loader.cc b/third_party/gopher-orch/src/gopher/orch/agent/config_loader.cc new file mode 100644 index 00000000..b0ef2209 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/agent/config_loader.cc @@ -0,0 +1,75 @@ +// ConfigLoader Implementation - File I/O operations + +#include "gopher/orch/agent/config_loader.h" + +#include +#include + +namespace gopher { +namespace orch { +namespace agent { + +VoidResult ConfigLoader::loadEnvFile(const std::string& path) { + std::ifstream file(path); + if (!file.is_open()) { + return VoidResult(Error(-1, "Cannot open .env file: " + path)); + } + + std::string line; + while (std::getline(file, line)) { + // Skip empty lines and comments + if (line.empty() || line[0] == '#') { + continue; + } + + // Find the = separator + auto pos = line.find('='); + if (pos == std::string::npos) { + continue; + } + + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); + + // Trim whitespace + while (!key.empty() && std::isspace(key.back())) + key.pop_back(); + while (!key.empty() && std::isspace(key.front())) + key.erase(0, 1); + while (!value.empty() && std::isspace(value.back())) + value.pop_back(); + while (!value.empty() && std::isspace(value.front())) + value.erase(0, 1); + + // Remove quotes if present + if (value.size() >= 2) { + if ((value.front() == '"' && value.back() == '"') || + (value.front() == '\'' && value.back() == '\'')) { + value = value.substr(1, value.size() - 2); + } + } + + if (!key.empty()) { + env_vars_[key] = value; + } + } + + return VoidResult(nullptr); +} + +Result ConfigLoader::loadFromFile(const std::string& path) { + std::ifstream file(path); + if (!file.is_open()) { + return Result( + Error(-1, "Cannot open config file: " + path)); + } + + std::stringstream buffer; + buffer << file.rdbuf(); + + return loadFromString(buffer.str()); +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/agent/tool_registry.cc b/third_party/gopher-orch/src/gopher/orch/agent/tool_registry.cc new file mode 100644 index 00000000..9d2b7cae --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/agent/tool_registry.cc @@ -0,0 +1,323 @@ +// ToolRegistry Config Loading Implementation + +#include +#include "gopher/orch/agent/tool_registry.h" + +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/agent/rest_tool_adapter.h" +#include "gopher/orch/agent/tool_definition.h" + +#ifdef GOPHER_ORCH_WITH_MCP +#include "gopher/orch/server/mcp_server.h" +#endif + +namespace gopher { +namespace orch { +namespace agent { + +// ═══════════════════════════════════════════════════════════════════════════ +// CONFIG LOADING +// ═══════════════════════════════════════════════════════════════════════════ + +void ToolRegistry::loadFromFile(const std::string& path, + Dispatcher& dispatcher, + std::function callback) { + ConfigLoader loader; + + // Copy env vars to loader + { + std::lock_guard lock(mutex_); + for (const auto& kv : env_vars_) { + loader.setEnv(kv.first, kv.second); + } + } + + auto result = loader.loadFromFile(path); + if (!mcp::holds_alternative(result)) { + dispatcher.post( + [callback = std::move(callback), err = mcp::get(result)]() { + callback(VoidResult(err)); + }); + return; + } + + loadConfig(mcp::get(result), dispatcher, std::move(callback)); +} + +void ToolRegistry::loadFromString(const std::string& json_string, + Dispatcher& dispatcher, + std::function callback) { + ConfigLoader loader; + + { + std::lock_guard lock(mutex_); + for (const auto& kv : env_vars_) { + loader.setEnv(kv.first, kv.second); + } + } + + auto result = loader.loadFromString(json_string); + if (!mcp::holds_alternative(result)) { + dispatcher.post( + [callback = std::move(callback), err = mcp::get(result)]() { + callback(VoidResult(err)); + }); + return; + } + + loadConfig(mcp::get(result), dispatcher, std::move(callback)); +} + +void ToolRegistry::loadConfig(const RegistryConfig& config, + Dispatcher& dispatcher, + std::function callback) { + // Track pending MCP server connections + auto pending = + std::make_shared>(config.mcp_servers.size()); + auto errors = std::make_shared>(); + auto self = this; + auto config_copy = std::make_shared(config); + + auto on_all_connected = [self, config_copy, callback, errors, + &dispatcher]() mutable { + // Register tools after all MCP servers connected + for (const auto& tool_def : config_copy->tools) { + auto result = self->registerTool(tool_def, dispatcher); + if (!mcp::holds_alternative(result)) { + errors->push_back("Tool " + tool_def.name + ": " + + mcp::get(result).message); + } + } + + if (!errors->empty()) { + std::string error_msg = "Errors during config load:"; + for (const auto& e : *errors) { + error_msg += "\n - " + e; + } + callback(VoidResult(Error(-1, error_msg))); + } else { + callback(VoidResult(nullptr)); + } + }; + + if (config.mcp_servers.empty()) { + dispatcher.post([on_all_connected]() mutable { on_all_connected(); }); + return; + } + + // Connect to MCP servers + for (const auto& server_def : config.mcp_servers) { + addMCPServer(server_def, dispatcher, + [pending, errors, on_all_connected, + name = server_def.name](VoidResult result) mutable { + if (!mcp::holds_alternative(result)) { + errors->push_back("MCP server " + name + ": " + + mcp::get(result).message); + } + + if (--(*pending) == 0) { + on_all_connected(); + } + }); + } +} + +VoidResult ToolRegistry::registerTool(const ToolDefinition& def, + Dispatcher& dispatcher) { + // Create ToolEntry from definition + ToolEntry entry; + entry.spec = def.toToolSpec(); + + // Handle different tool types + if (def.handler) { + // Lambda handler + entry.function = *def.handler; + } else if (def.rest_endpoint) { + // REST endpoint - create adapter + auto adapter = std::make_shared(); + + // Copy env vars + { + std::lock_guard lock(mutex_); + for (const auto& kv : env_vars_) { + adapter->setEnv(kv.first, kv.second); + } + } + + entry.function = adapter->createToolFunction(def); + if (!entry.function) { + return VoidResult(Error(-1, "Failed to create REST tool: " + def.name)); + } + } else if (def.mcp_reference) { + // MCP reference - proxy to MCP server + const auto& ref = *def.mcp_reference; + ServerPtr server = getMCPServer(ref.server_name); + + if (server) { + entry.server = server; + entry.original_name = ref.tool_name; + } else { + return VoidResult(Error(-1, "MCP server not found: " + ref.server_name)); + } + } else { + return VoidResult(Error( + -1, + "Tool has no handler, REST endpoint, or MCP reference: " + def.name)); + } + + // Register the tool + { + std::lock_guard lock(mutex_); + tools_[def.name] = std::move(entry); + } + + return VoidResult(nullptr); +} + +VoidResult ToolRegistry::loadEnvFile(const std::string& path) { + ConfigLoader loader; + auto result = loader.loadEnvFile(path); + if (!mcp::holds_alternative(result)) { + return result; + } + + // Note: The loader only loads to its internal state + // We need to read the file directly here + std::ifstream file(path); + if (!file.is_open()) { + return VoidResult(Error(-1, "Cannot open .env file: " + path)); + } + + std::string line; + while (std::getline(file, line)) { + if (line.empty() || line[0] == '#') + continue; + + auto pos = line.find('='); + if (pos == std::string::npos) + continue; + + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); + + // Trim + while (!key.empty() && std::isspace(key.back())) + key.pop_back(); + while (!key.empty() && std::isspace(key.front())) + key.erase(0, 1); + while (!value.empty() && std::isspace(value.back())) + value.pop_back(); + while (!value.empty() && std::isspace(value.front())) + value.erase(0, 1); + + // Remove quotes + if (value.size() >= 2) { + if ((value.front() == '"' && value.back() == '"') || + (value.front() == '\'' && value.back() == '\'')) { + value = value.substr(1, value.size() - 2); + } + } + + if (!key.empty()) { + setEnv(key, value); + } + } + + return VoidResult(nullptr); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MCP SERVER MANAGEMENT +// ═══════════════════════════════════════════════════════════════════════════ + +void ToolRegistry::addMCPServer(const MCPServerDefinition& def, + Dispatcher& dispatcher, + std::function callback) { +#ifdef GOPHER_ORCH_WITH_MCP + using namespace gopher::orch::server; + + MCPServerConfig config; + config.name = def.name; + config.connect_timeout = def.connect_timeout; + config.request_timeout = def.request_timeout; + config.max_connect_retries = def.max_retries; + + // Configure transport + switch (def.transport) { + case MCPServerDefinition::TransportType::STDIO: { + if (!def.stdio_config) { + dispatcher.post([callback = std::move(callback)]() { + callback(VoidResult(Error(-1, "STDIO config missing"))); + }); + return; + } + config.transport_type = MCPServerConfig::TransportType::STDIO; + config.stdio_transport.command = def.stdio_config->command; + config.stdio_transport.args = def.stdio_config->args; + config.stdio_transport.env = def.stdio_config->env; + config.stdio_transport.working_directory = + def.stdio_config->working_directory; + break; + } + + case MCPServerDefinition::TransportType::HTTP_SSE: { + if (!def.http_sse_config) { + dispatcher.post([callback = std::move(callback)]() { + callback(VoidResult(Error(-1, "HTTP-SSE config missing"))); + }); + return; + } + config.transport_type = MCPServerConfig::TransportType::HTTP_SSE; + config.http_sse_transport.url = def.http_sse_config->url; + config.http_sse_transport.headers = def.http_sse_config->headers; + config.http_sse_transport.verify_ssl = def.http_sse_config->verify_ssl; + break; + } + + case MCPServerDefinition::TransportType::WEBSOCKET: { + if (!def.websocket_config) { + dispatcher.post([callback = std::move(callback)]() { + callback(VoidResult(Error(-1, "WebSocket config missing"))); + }); + return; + } + config.transport_type = MCPServerConfig::TransportType::WEBSOCKET; + config.websocket_transport.url = def.websocket_config->url; + config.websocket_transport.headers = def.websocket_config->headers; + config.websocket_transport.verify_ssl = def.websocket_config->verify_ssl; + break; + } + } + + // Create and connect MCP server + MCPServer::create(config, dispatcher, + [this, name = def.name, callback = std::move(callback)]( + Result result) { + if (!mcp::holds_alternative(result)) { + callback(VoidResult(mcp::get(result))); + return; + } + + auto server = mcp::get(result); + + // Store in registry + { + std::lock_guard lock(mutex_); + mcp_servers_[name] = server; + servers_.push_back(server); + } + + callback(VoidResult(nullptr)); + }); +#else + // MCP not available + dispatcher.post([callback = std::move(callback)]() { + callback(VoidResult(Error( + -1, "MCP support not compiled (GOPHER_ORCH_WITH_MCP not defined)"))); + }); +#endif +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/agent/tool_runnable.cc b/third_party/gopher-orch/src/gopher/orch/agent/tool_runnable.cc new file mode 100644 index 00000000..851825ab --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/agent/tool_runnable.cc @@ -0,0 +1,213 @@ +// ToolRunnable Implementation + +#include "gopher/orch/agent/tool_runnable.h" + +#include + +namespace gopher { +namespace orch { +namespace agent { + +// ============================================================================= +// Factory +// ============================================================================= + +ToolRunnable::Ptr ToolRunnable::create(ToolExecutorPtr executor) { + return Ptr(new ToolRunnable(std::move(executor))); +} + +ToolRunnable::ToolRunnable(ToolExecutorPtr executor) + : executor_(std::move(executor)) {} + +// ============================================================================= +// Runnable Interface +// ============================================================================= + +std::string ToolRunnable::name() const { return "ToolRunnable"; } + +void ToolRunnable::invoke(const JsonValue& input, + const RunnableConfig& /* config */, + Dispatcher& dispatcher, + Callback callback) { + // Validate executor + if (!executor_) { + postError(dispatcher, std::move(callback), + OrchError::INVALID_ARGUMENT, + "No tool executor configured"); + return; + } + + // Check if input has tool_calls array (multiple calls) + if (input.isObject() && input.contains("tool_calls") && + input["tool_calls"].isArray()) { + auto calls = parseMultipleCalls(input); + if (calls.empty()) { + postError(dispatcher, std::move(callback), + OrchError::INVALID_ARGUMENT, + "Empty tool_calls array"); + return; + } + executeMultiple(calls, dispatcher, std::move(callback)); + return; + } + + // Single tool call + auto single = parseSingleCall(input); + if (!single.valid) { + postError(dispatcher, std::move(callback), + OrchError::INVALID_ARGUMENT, + "Invalid tool call input: missing 'name' field"); + return; + } + + executeSingle(single.id, single.name, single.arguments, dispatcher, + std::move(callback)); +} + +// ============================================================================= +// Execution +// ============================================================================= + +void ToolRunnable::executeSingle(const std::string& id, + const std::string& name, + const JsonValue& arguments, + Dispatcher& dispatcher, + Callback callback) { + executor_->executeTool( + name, arguments, dispatcher, + [id, callback = std::move(callback)](Result result) mutable { + JsonValue output = JsonValue::object(); + if (!id.empty()) { + output["id"] = id; + } + + if (mcp::holds_alternative(result)) { + output["success"] = false; + output["error"] = mcp::get(result).message; + // Still return success Result with error info in JSON + callback(Result(std::move(output))); + } else { + output["success"] = true; + output["result"] = mcp::get(result); + callback(Result(std::move(output))); + } + }); +} + +void ToolRunnable::executeMultiple(const std::vector& calls, + Dispatcher& dispatcher, + Callback callback) { + // Use the executor's parallel execution + executor_->executeToolCalls( + calls, true, // parallel = true + dispatcher, + [calls, callback = std::move(callback)]( + std::vector> results) mutable { + JsonValue output = JsonValue::object(); + JsonValue results_array = JsonValue::array(); + + for (size_t i = 0; i < calls.size(); ++i) { + JsonValue result_obj = JsonValue::object(); + result_obj["id"] = calls[i].id; + + if (i < results.size()) { + if (mcp::holds_alternative(results[i])) { + result_obj["success"] = true; + result_obj["result"] = mcp::get(results[i]); + } else { + result_obj["success"] = false; + result_obj["error"] = mcp::get(results[i]).message; + } + } else { + result_obj["success"] = false; + result_obj["error"] = "No result returned"; + } + + results_array.push_back(result_obj); + } + + output["results"] = results_array; + callback(Result(std::move(output))); + }); +} + +// ============================================================================= +// Parsing +// ============================================================================= + +ToolRunnable::SingleCall ToolRunnable::parseSingleCall(const JsonValue& input) { + SingleCall result; + + if (!input.isObject()) { + return result; + } + + // Get name (required) + if (input.contains("name") && input["name"].isString()) { + result.name = input["name"].getString(); + result.valid = true; + } else { + return result; + } + + // Get id (optional) + if (input.contains("id") && input["id"].isString()) { + result.id = input["id"].getString(); + } + + // Get arguments (optional, default to empty object) + if (input.contains("arguments")) { + result.arguments = input["arguments"]; + } else { + result.arguments = JsonValue::object(); + } + + return result; +} + +std::vector ToolRunnable::parseMultipleCalls(const JsonValue& input) { + std::vector calls; + + if (!input.isObject() || !input.contains("tool_calls") || + !input["tool_calls"].isArray()) { + return calls; + } + + const auto& calls_array = input["tool_calls"]; + for (size_t i = 0; i < calls_array.size(); ++i) { + const auto& call_obj = calls_array[i]; + if (!call_obj.isObject()) { + continue; + } + + ToolCall call; + + // Get name (required) + if (!call_obj.contains("name") || !call_obj["name"].isString()) { + continue; + } + call.name = call_obj["name"].getString(); + + // Get id (optional, generate if missing) + if (call_obj.contains("id") && call_obj["id"].isString()) { + call.id = call_obj["id"].getString(); + } else { + call.id = "call_" + std::to_string(i); + } + + // Get arguments + if (call_obj.contains("arguments")) { + call.arguments = call_obj["arguments"]; + } else { + call.arguments = JsonValue::object(); + } + + calls.push_back(std::move(call)); + } + + return calls; +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/agent/tools_fetcher.cpp b/third_party/gopher-orch/src/gopher/orch/agent/tools_fetcher.cpp new file mode 100644 index 00000000..844b9122 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/agent/tools_fetcher.cpp @@ -0,0 +1,197 @@ +/** + * @file tools_fetcher.cpp + * @brief Implementation of ToolsFetcher orchestration layer + */ + +#include "gopher/orch/agent/tools_fetcher.h" + +#include +#include +#include +#include + +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/server/server_composite.h" + +#ifdef GOPHER_ORCH_WITH_MCP +#include "gopher/orch/server/mcp_server.h" +#endif + +namespace gopher { +namespace orch { +namespace agent { + +void ToolsFetcher::loadFromJson(const std::string& json_config, + Dispatcher& dispatcher, + std::function callback) { + // Initialize ConfigLoader if needed + if (!config_loader_) { + config_loader_ = std::make_shared(); + } + + // Parse JSON configuration + auto config_result = config_loader_->loadFromString(json_config); + if (!mcp::holds_alternative(config_result)) { + dispatcher.post([callback, err = mcp::get(config_result)]() { + callback(VoidResult(err)); + }); + return; + } + + auto config = mcp::get(config_result); + +#ifdef GOPHER_ORCH_WITH_MCP + // Create ServerComposite + composite_ = server::ServerComposite::create("ToolComposite"); + + // Handle no servers case + if (config.mcp_servers.empty()) { + // Create empty ToolRegistry with composite + registry_ = std::make_shared(); + registry_->setServerComposite(composite_); + dispatcher.post([callback]() { + callback(VoidResult(nullptr)); + }); + return; + } + + // Track pending server connections + auto pending = std::make_shared>(config.mcp_servers.size()); + auto errors = std::make_shared>(); + auto servers = std::make_shared>>(); + + // CRITICAL FIX: Capture dispatcher by pointer instead of reference to avoid + // dangling reference when lambdas are invoked asynchronously after this + // function returns. The dispatcher is owned by the caller and must outlive + // all async operations. + Dispatcher* dispatcher_ptr = &dispatcher; + + // Create completion handler + auto on_all_connected = [this, dispatcher_ptr, callback, errors, servers, pending]() { + if (!errors->empty()) { + std::string error_msg = "Server connection errors:"; + for (const auto& e : *errors) { + error_msg += "\n - " + e; + } + callback(VoidResult(Error(-1, error_msg))); + return; + } + + // Create ToolRegistry with composite delegation + registry_ = std::make_shared(); + registry_->setServerComposite(this->composite_); + + // Check if we have any servers to discover tools from + if (servers->empty()) { + // No servers connected successfully, but still call callback with success + // The client can check registry->toolCount() to know no tools were discovered + dispatcher_ptr->post([callback]() { + callback(VoidResult(nullptr)); + }); + return; + } + + // Track pending tool discoveries (registry->addServer now delegates to composite) + auto discovery_pending = std::make_shared>(servers->size()); + auto discovery_complete = [callback, discovery_pending, dispatcher_ptr, registry = this->registry_]() { + if (--(*discovery_pending) == 0) { + // All discoveries complete - add a final dispatch to ensure registry is updated + dispatcher_ptr->post([callback, registry, dispatcher_ptr]() { + // Give one more event loop cycle for registry updates to complete + dispatcher_ptr->post([callback]() { + callback(VoidResult(nullptr)); + }); + }); + } + }; + + // Add all servers to registry (which now delegates to composite internally) + for (const auto& server_pair : *servers) { + const server::MCPServerPtr& server = server_pair.second; + + // addServer() now handles both registry and composite registration + this->registry_->addServer(server, *dispatcher_ptr); + discovery_complete(); + } + }; + + // Connect to each MCP server + for (const auto& server_def : config.mcp_servers) { + // Convert to MCPServerConfig + server::MCPServerConfig mcp_config; + mcp_config.name = server_def.name; + mcp_config.connect_timeout = std::chrono::milliseconds(server_def.connect_timeout); + mcp_config.request_timeout = std::chrono::milliseconds(server_def.request_timeout); + + // Configure transport + if (server_def.transport == MCPServerDefinition::TransportType::HTTP_SSE && + server_def.http_sse_config) { + mcp_config.transport_type = server::MCPServerConfig::TransportType::HTTP_SSE; + mcp_config.http_sse_transport.url = server_def.http_sse_config->url; + mcp_config.http_sse_transport.headers = server_def.http_sse_config->headers; + } + + // Track completion for this server + auto server_name = server_def.name; + + // Create and connect to the server + server::MCPServer::create(mcp_config, dispatcher, + [servers, errors, pending, on_all_connected, server_name] + (Result result) { + + if (mcp::holds_alternative(result)) { + servers->push_back({server_name, mcp::get(result)}); + } else { + errors->push_back(server_name + ": " + mcp::get(result).message); + } + + if (--(*pending) == 0) { + on_all_connected(); + } + }); + } +#else + // MCP not available + dispatcher.post([callback]() { + callback(VoidResult(Error(-1, "MCP support not compiled in"))); + }); +#endif +} + +void ToolsFetcher::loadFromFile(const std::string& file_path, + Dispatcher& dispatcher, + std::function callback) { + // Read file contents + std::ifstream file(file_path); + if (!file.is_open()) { + dispatcher.post([callback, file_path]() { + callback(VoidResult(Error(-1, "Cannot open file: " + file_path))); + }); + return; + } + + std::stringstream buffer; + buffer << file.rdbuf(); + file.close(); + + // Call loadFromJson with file contents + loadFromJson(buffer.str(), dispatcher, callback); +} + +void ToolsFetcher::shutdown(Dispatcher& dispatcher, + std::function callback) { +#ifdef GOPHER_ORCH_WITH_MCP + if (composite_) { + composite_->disconnectAll(dispatcher, std::move(callback)); + } else { + dispatcher.post(std::move(callback)); + } +#else + dispatcher.post(std::move(callback)); +#endif +} + +} // namespace agent +} // namespace orch +} // namespace gopher \ No newline at end of file diff --git a/third_party/gopher-orch/src/gopher/orch/ffi/orch_ffi_agent.cc b/third_party/gopher-orch/src/gopher/orch/ffi/orch_ffi_agent.cc new file mode 100644 index 00000000..1b7d2fb4 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/ffi/orch_ffi_agent.cc @@ -0,0 +1,252 @@ +/** + * @file orch_ffi_agent.cc + * @brief FFI implementation for Agent API functions + * + * This file implements the C API functions for agent functionality, + * providing FFI-safe wrappers around the C++ ReActAgent class. + */ + +#include "gopher/orch/ffi/orch_ffi.h" +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/agent/agent.h" +#include "gopher/orch/agent/api_engine.h" +#include + +using namespace gopher::orch::ffi; +using namespace gopher::orch::agent; + +extern "C" { + +/* ============================================================================ + * Helper Functions + * ============================================================================ + */ + +/** + * Validate handle and cast to AgentImpl + */ +static AgentImpl* ValidateAgentHandle(gopher_orch_agent_t handle) { + if (!handle) { + SET_ERROR(GOPHER_ORCH_ERROR_NULL_POINTER, "Agent handle is NULL"); + return nullptr; + } + + if (!HandleRegistry::Instance().IsValid(handle)) { + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_HANDLE, "Agent handle is invalid"); + return nullptr; + } + + auto* impl = reinterpret_cast(handle); + if (impl->GetType() != GOPHER_ORCH_TYPE_AGENT) { + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_ARGUMENT, "Handle is not an agent"); + return nullptr; + } + + return impl; +} + +/** + * Copy string to C-style allocated memory + */ +static char* AllocateString(const std::string& str) { + char* result = static_cast(malloc(str.length() + 1)); + if (result) { + std::strcpy(result, str.c_str()); + } + return result; +} + +/* ============================================================================ + * Agent API Implementation + * ============================================================================ + */ + +GOPHER_ORCH_API gopher_orch_agent_t gopher_orch_agent_create_by_json( + const char* provider_name, + const char* model_name, + const char* server_json_config) GOPHER_ORCH_NOEXCEPT { + + ErrorManager::ClearError(); + + try { + // Validate parameters + if (!provider_name || !model_name || !server_json_config) { + SET_ERROR(GOPHER_ORCH_ERROR_NULL_POINTER, "Required parameter is NULL"); + return nullptr; + } + + // Create agent using the existing C++ API + auto agent = ReActAgent::createByJson( + std::string(provider_name), + std::string(model_name), + std::string(server_json_config) + ); + + if (!agent) { + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_ARGUMENT, + "Failed to create agent from JSON configuration"); + return nullptr; + } + + // Wrap in FFI handle + auto* impl = new AgentImpl(agent); + return reinterpret_cast(impl); + + } catch (const std::exception& e) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + std::string("Exception in agent creation: ") + e.what()); + return nullptr; + } catch (...) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + "Unknown exception in agent creation"); + return nullptr; + } +} + +GOPHER_ORCH_API gopher_orch_agent_t gopher_orch_agent_create_by_api_key( + const char* provider_name, + const char* model_name, + const char* api_key) GOPHER_ORCH_NOEXCEPT { + + ErrorManager::ClearError(); + + try { + // Validate parameters + if (!provider_name || !model_name || !api_key) { + SET_ERROR(GOPHER_ORCH_ERROR_NULL_POINTER, "Required parameter is NULL"); + return nullptr; + } + + // Create agent using the existing C++ API + auto agent = ReActAgent::createByApiKey( + std::string(provider_name), + std::string(model_name), + std::string(api_key) + ); + + if (!agent) { + SET_ERROR(GOPHER_ORCH_ERROR_INVALID_ARGUMENT, + "Failed to create agent with API key"); + return nullptr; + } + + // Wrap in FFI handle + auto* impl = new AgentImpl(agent); + return reinterpret_cast(impl); + + } catch (const std::exception& e) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + std::string("Exception in agent creation: ") + e.what()); + return nullptr; + } catch (...) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + "Unknown exception in agent creation"); + return nullptr; + } +} + +GOPHER_ORCH_API char* gopher_orch_agent_run( + gopher_orch_agent_t agent, + const char* query, + uint64_t timeout_ms) GOPHER_ORCH_NOEXCEPT { + + ErrorManager::ClearError(); + + try { + // Validate agent handle + auto* impl = ValidateAgentHandle(agent); + if (!impl) { + return nullptr; // Error already set by ValidateAgentHandle + } + + // Validate query + if (!query) { + SET_ERROR(GOPHER_ORCH_ERROR_NULL_POINTER, "Query is NULL"); + return nullptr; + } + + // Run the agent + // Note: The current ReActAgent::run() method doesn't support timeout parameter + // TODO: Add timeout support to the C++ API if needed + std::string response = impl->agent->run(std::string(query)); + + // Allocate and return response string + return AllocateString(response); + + } catch (const std::exception& e) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + std::string("Exception in agent run: ") + e.what()); + return nullptr; + } catch (...) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + "Unknown exception in agent run"); + return nullptr; + } +} + +GOPHER_ORCH_API void gopher_orch_agent_add_ref( + gopher_orch_agent_t agent) GOPHER_ORCH_NOEXCEPT { + + if (auto* impl = ValidateAgentHandle(agent)) { + impl->AddRef(); + } +} + +GOPHER_ORCH_API void gopher_orch_agent_release( + gopher_orch_agent_t agent) GOPHER_ORCH_NOEXCEPT { + + if (auto* impl = ValidateAgentHandle(agent)) { + impl->Release(); + } +} + +GOPHER_ORCH_API char* gopher_orch_api_fetch_servers( + const char* api_key) GOPHER_ORCH_NOEXCEPT { + + ErrorManager::ClearError(); + + try { + // Validate parameter + if (!api_key) { + SET_ERROR(GOPHER_ORCH_ERROR_NULL_POINTER, "API key is NULL"); + return nullptr; + } + + // Fetch server configuration using the existing C++ API + std::string config = ApiEngine::fetchMcpServers(std::string(api_key)); + + // Allocate and return configuration string + return AllocateString(config); + + } catch (const std::exception& e) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + std::string("Exception in API fetch: ") + e.what()); + return nullptr; + } catch (...) { + SET_ERROR(GOPHER_ORCH_ERROR_INTERNAL, + "Unknown exception in API fetch"); + return nullptr; + } +} + +/* ============================================================================ + * Error Handling Functions + * ============================================================================ + */ + +GOPHER_ORCH_API const gopher_orch_error_info_t* gopher_orch_last_error(void) + GOPHER_ORCH_NOEXCEPT { + return ErrorManager::GetLastError(); +} + +GOPHER_ORCH_API void gopher_orch_clear_error(void) GOPHER_ORCH_NOEXCEPT { + ErrorManager::ClearError(); +} + +GOPHER_ORCH_API void gopher_orch_free(void* ptr) GOPHER_ORCH_NOEXCEPT { + if (ptr) { + free(ptr); + } +} + +} /* extern "C" */ \ No newline at end of file diff --git a/third_party/gopher-orch/src/gopher/orch/llm/anthropic_provider.cc b/third_party/gopher-orch/src/gopher/orch/llm/anthropic_provider.cc new file mode 100644 index 00000000..b8dfb31f --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/llm/anthropic_provider.cc @@ -0,0 +1,464 @@ +// Anthropic Provider Implementation + +#include "gopher/orch/llm/anthropic_provider.h" + +#include +#include +#include +#include + +#include "gopher/orch/server/rest_server.h" + +namespace gopher { +namespace orch { +namespace llm { + +using namespace gopher::orch::core; +using namespace gopher::orch::server; + +// ═══════════════════════════════════════════════════════════════════════════ +// IMPLEMENTATION +// ═══════════════════════════════════════════════════════════════════════════ + +class AnthropicProvider::Impl { + public: + AnthropicConfig config; + HttpClientPtr http_client; + mutable std::mutex mutex; + + explicit Impl(const AnthropicConfig& cfg) : config(cfg) { + // Use CurlHttpClient for real HTTP requests + http_client = server::createCurlHttpClient(); + } + + std::string messagesEndpoint() const { + return config.base_url + "/v1/messages"; + } + + std::map headers() const { + std::map hdrs; + hdrs["Content-Type"] = "application/json"; + hdrs["x-api-key"] = config.api_key; + hdrs["anthropic-version"] = config.api_version; + + // Add beta headers if any + if (!config.betas.empty()) { + std::string beta_str; + for (size_t i = 0; i < config.betas.size(); ++i) { + if (i > 0) + beta_str += ","; + beta_str += config.betas[i]; + } + hdrs["anthropic-beta"] = beta_str; + } + + return hdrs; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// FACTORY METHODS +// ═══════════════════════════════════════════════════════════════════════════ + +namespace { +// Helper to get API key from parameter or environment variable +std::string resolveApiKey(const std::string& api_key) { + if (!api_key.empty()) { + return api_key; + } + + // Try to get from environment variable + const char* env_key = std::getenv("ANTHROPIC_API_KEY"); + if (env_key && env_key[0] != '\0') { + return std::string(env_key); + } + + return ""; +} +} // namespace + +AnthropicProvider::Ptr AnthropicProvider::create(const std::string& api_key) { + return create(AnthropicConfig(api_key)); +} + +AnthropicProvider::Ptr AnthropicProvider::create(const std::string& api_key, + const std::string& base_url) { + AnthropicConfig config(api_key); + if (!base_url.empty()) { + config.withBaseUrl(base_url); + } + return create(config); +} + +AnthropicProvider::Ptr AnthropicProvider::create( + const AnthropicConfig& config) { + // Resolve API key from config or environment variable + std::string resolved_key = resolveApiKey(config.api_key); + + if (resolved_key.empty()) { + throw std::runtime_error( + "ANTHROPIC_API_KEY not set. Please set the ANTHROPIC_API_KEY " + "environment variable or pass the API key directly."); + } + + // Create config with resolved key + AnthropicConfig resolved_config = config; + resolved_config.api_key = resolved_key; + + return Ptr(new AnthropicProvider(resolved_config)); +} + +AnthropicProvider::AnthropicProvider(const AnthropicConfig& config) + : impl_(std::make_unique(config)) {} + +AnthropicProvider::~AnthropicProvider() = default; + +// ═══════════════════════════════════════════════════════════════════════════ +// CHAT COMPLETION +// ═══════════════════════════════════════════════════════════════════════════ + +void AnthropicProvider::chat(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) { + auto request = buildRequest(messages, tools, config, false); + auto request_body = request.toString(); + + auto url = impl_->messagesEndpoint(); + auto headers = impl_->headers(); + + impl_->http_client->request( + HttpMethod::POST, url, headers, request_body, dispatcher, + [this, callback = std::move(callback)](Result result) { + if (!mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + return; + } + + auto& response = mcp::get(result); + if (!response.isSuccess()) { + std::string error_msg = + "HTTP " + std::to_string(response.status_code); + try { + auto error_json = JsonValue::parse(response.body); + if (error_json.contains("error") && + error_json["error"].contains("message")) { + error_msg = error_json["error"]["message"].getString(); + } + } catch (...) { + error_msg += ": " + response.body; + } + + int error_code = LLMError::UNKNOWN; + if (response.status_code == 401) { + error_code = LLMError::INVALID_API_KEY; + } else if (response.status_code == 429) { + error_code = LLMError::RATE_LIMITED; + } else if (response.status_code >= 500) { + error_code = LLMError::SERVICE_UNAVAILABLE; + } + + callback(Result(Error(error_code, error_msg))); + return; + } + + try { + auto response_json = JsonValue::parse(response.body); + auto parsed = parseResponse(response_json); + callback(std::move(parsed)); + } catch (const std::exception& e) { + callback(Result( + Error(LLMError::PARSE_ERROR, + std::string("Failed to parse response: ") + e.what()))); + } + }); +} + +void AnthropicProvider::chatStream(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + StreamCallback on_chunk, + ChatCallback on_complete) { + // Fall back to non-streaming for now + chat(messages, tools, config, dispatcher, std::move(on_complete)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// REQUEST/RESPONSE BUILDING +// ═══════════════════════════════════════════════════════════════════════════ + +JsonValue AnthropicProvider::buildRequest(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + bool stream) const { + JsonValue request = JsonValue::object(); + + // Model + request["model"] = config.model; + + // Convert messages (extract system separately) + auto [system_prompt, anthropic_messages] = + messagesToAnthropicFormat(messages); + + if (!system_prompt.empty()) { + request["system"] = system_prompt; + } + request["messages"] = anthropic_messages; + + // Max tokens (required for Anthropic) + request["max_tokens"] = config.max_tokens.value_or(4096); + + // Tools (if any) + if (!tools.empty()) { + JsonValue tool_array = JsonValue::array(); + for (const auto& tool : tools) { + tool_array.push_back(toolToJson(tool)); + } + request["tools"] = tool_array; + } + + // Optional parameters + if (config.temperature.has_value()) { + request["temperature"] = *config.temperature; + } + if (config.top_p.has_value()) { + request["top_p"] = *config.top_p; + } + if (config.stop.has_value() && !config.stop->empty()) { + JsonValue stop_array = JsonValue::array(); + for (const auto& s : *config.stop) { + stop_array.push_back(s); + } + request["stop_sequences"] = stop_array; + } + + if (stream) { + request["stream"] = true; + } + + return request; +} + +std::pair AnthropicProvider::messagesToAnthropicFormat( + const std::vector& messages) const { + std::string system_prompt; + JsonValue anthropic_messages = JsonValue::array(); + + for (const auto& msg : messages) { + if (msg.role == Role::SYSTEM) { + // Anthropic has separate system field + if (!system_prompt.empty()) { + system_prompt += "\n\n"; + } + system_prompt += msg.content; + continue; + } + + JsonValue json_msg = JsonValue::object(); + + if (msg.role == Role::USER) { + json_msg["role"] = "user"; + + // Check if this is a tool result + if (msg.tool_call_id.has_value()) { + // Tool result format for Anthropic + JsonValue content = JsonValue::array(); + JsonValue tool_result = JsonValue::object(); + tool_result["type"] = "tool_result"; + tool_result["tool_use_id"] = *msg.tool_call_id; + tool_result["content"] = msg.content; + content.push_back(tool_result); + json_msg["content"] = content; + } else { + json_msg["content"] = msg.content; + } + + } else if (msg.role == Role::TOOL) { + // Tool results in Anthropic go in a user message + json_msg["role"] = "user"; + JsonValue content = JsonValue::array(); + JsonValue tool_result = JsonValue::object(); + tool_result["type"] = "tool_result"; + if (msg.tool_call_id.has_value()) { + tool_result["tool_use_id"] = *msg.tool_call_id; + } + tool_result["content"] = msg.content; + content.push_back(tool_result); + json_msg["content"] = content; + + } else if (msg.role == Role::ASSISTANT) { + json_msg["role"] = "assistant"; + + if (msg.hasToolCalls()) { + // Assistant message with tool use + JsonValue content = JsonValue::array(); + + // Add text content if present + if (!msg.content.empty()) { + JsonValue text_block = JsonValue::object(); + text_block["type"] = "text"; + text_block["text"] = msg.content; + content.push_back(text_block); + } + + // Add tool use blocks + for (const auto& tc : *msg.tool_calls) { + JsonValue tool_use = JsonValue::object(); + tool_use["type"] = "tool_use"; + tool_use["id"] = tc.id; + tool_use["name"] = tc.name; + tool_use["input"] = tc.arguments; + content.push_back(tool_use); + } + + json_msg["content"] = content; + } else { + json_msg["content"] = msg.content; + } + } + + anthropic_messages.push_back(json_msg); + } + + return {system_prompt, anthropic_messages}; +} + +Result AnthropicProvider::parseResponse( + const JsonValue& response) const { + LLMResponse result; + + try { + // Parse stop reason + if (response.contains("stop_reason") && !response["stop_reason"].isNull()) { + std::string stop_reason = response["stop_reason"].getString(); + // Map Anthropic stop reasons to our format + if (stop_reason == "end_turn") { + result.finish_reason = "stop"; + } else if (stop_reason == "tool_use") { + result.finish_reason = "tool_calls"; + } else if (stop_reason == "max_tokens") { + result.finish_reason = "length"; + } else { + result.finish_reason = stop_reason; + } + } + + result.message.role = Role::ASSISTANT; + + // Parse content array + if (response.contains("content") && response["content"].isArray()) { + std::string text_content; + std::vector tool_calls; + + const auto& content_array = response["content"]; + for (size_t i = 0; i < content_array.size(); ++i) { + const auto& block = content_array[i]; + std::string block_type = + block.contains("type") ? block["type"].getString() : ""; + + if (block_type == "text") { + if (!text_content.empty()) { + text_content += "\n"; + } + text_content += block["text"].getString(); + + } else if (block_type == "tool_use") { + ToolCall tc; + tc.id = block["id"].getString(); + tc.name = block["name"].getString(); + tc.arguments = block["input"]; + tool_calls.push_back(std::move(tc)); + } + } + + result.message.content = text_content; + if (!tool_calls.empty()) { + result.message.tool_calls = std::move(tool_calls); + } + } + + // Parse usage + if (response.contains("usage")) { + const auto& usage = response["usage"]; + Usage u; + u.prompt_tokens = + usage.contains("input_tokens") ? usage["input_tokens"].getInt() : 0; + u.completion_tokens = + usage.contains("output_tokens") ? usage["output_tokens"].getInt() : 0; + u.total_tokens = u.prompt_tokens + u.completion_tokens; + result.usage = u; + } + + return Result(std::move(result)); + + } catch (const std::exception& e) { + return Result( + Error(LLMError::PARSE_ERROR, std::string("Parse error: ") + e.what())); + } +} + +JsonValue AnthropicProvider::toolToJson(const ToolSpec& tool) const { + JsonValue json = JsonValue::object(); + json["name"] = tool.name; + json["description"] = tool.description; + + // Anthropic expects input_schema with a type field + JsonValue input_schema = tool.parameters; + + // Ensure the schema has a type field (Anthropic requires this) + if (!input_schema.contains("type")) { + // If no type is specified, assume it's an object type + input_schema["type"] = "object"; + } + + json["input_schema"] = input_schema; + + return json; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MODEL SUPPORT +// ═══════════════════════════════════════════════════════════════════════════ + +bool AnthropicProvider::isModelSupported(const std::string& model) const { + // Accept any model - Anthropic will validate + return !model.empty(); +} + +std::vector AnthropicProvider::supportedModels() const { + return {"claude-3-5-sonnet-latest", "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-latest", "claude-3-5-haiku-20241022", + "claude-3-opus-20240229", "claude-3-sonnet-20240229", + "claude-3-haiku-20240307", "claude-opus-4-5-20251101", + "claude-sonnet-4-20250514"}; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════ + +std::string AnthropicProvider::endpoint() const { + return impl_->messagesEndpoint(); +} + +bool AnthropicProvider::isConfigured() const { + return !impl_->config.api_key.empty(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// FACTORY FUNCTION +// ═══════════════════════════════════════════════════════════════════════════ + +LLMProviderPtr createAnthropicProvider(const std::string& api_key, + const std::string& base_url) { + if (base_url.empty()) { + return AnthropicProvider::create(api_key); + } + return AnthropicProvider::create(api_key, base_url); +} + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/llm/llm_factory.cc b/third_party/gopher-orch/src/gopher/orch/llm/llm_factory.cc new file mode 100644 index 00000000..2eee8c39 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/llm/llm_factory.cc @@ -0,0 +1,60 @@ +// LLM Provider Factory Implementation + +#include "gopher/orch/llm/anthropic_provider.h" +#include "gopher/orch/llm/llm_provider.h" +#include "gopher/orch/llm/openai_provider.h" + +namespace gopher { +namespace orch { +namespace llm { + +LLMProviderPtr createProvider(const ProviderConfig& config) { + switch (config.type) { + case ProviderType::OPENAI: { + OpenAIConfig openai_config(config.api_key); + if (!config.base_url.empty()) { + openai_config.withBaseUrl(config.base_url); + } + return OpenAIProvider::create(openai_config); + } + + case ProviderType::ANTHROPIC: { + AnthropicConfig anthropic_config(config.api_key); + if (!config.base_url.empty()) { + anthropic_config.withBaseUrl(config.base_url); + } + return AnthropicProvider::create(anthropic_config); + } + + case ProviderType::OLLAMA: { + // Ollama uses OpenAI-compatible API + OpenAIConfig ollama_config(""); + ollama_config.withBaseUrl(config.base_url.empty() + ? "http://localhost:11434/v1" + : config.base_url); + return OpenAIProvider::create(ollama_config); + } + + case ProviderType::CUSTOM: { + // For custom providers, use OpenAI-compatible API by default + OpenAIConfig custom_config(config.api_key); + if (!config.base_url.empty()) { + custom_config.withBaseUrl(config.base_url); + } + return OpenAIProvider::create(custom_config); + } + + default: + return nullptr; + } +} + +LLMProviderPtr createOllamaProvider(const std::string& base_url) { + ProviderConfig config(ProviderType::OLLAMA); + config.base_url = base_url.empty() ? "http://localhost:11434/v1" : base_url; + return createProvider(config); +} + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/llm/llm_runnable.cc b/third_party/gopher-orch/src/gopher/orch/llm/llm_runnable.cc new file mode 100644 index 00000000..cfdcf52e --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/llm/llm_runnable.cc @@ -0,0 +1,248 @@ +// LLMRunnable Implementation + +#include "gopher/orch/llm/llm_runnable.h" + +namespace gopher { +namespace orch { +namespace llm { + +// ============================================================================= +// Factory +// ============================================================================= + +LLMRunnable::Ptr LLMRunnable::create(LLMProviderPtr provider, + const LLMConfig& config) { + return Ptr(new LLMRunnable(std::move(provider), config)); +} + +LLMRunnable::LLMRunnable(LLMProviderPtr provider, const LLMConfig& config) + : provider_(std::move(provider)), default_config_(config) {} + +// ============================================================================= +// Runnable Interface +// ============================================================================= + +std::string LLMRunnable::name() const { + if (provider_) { + return "LLMRunnable(" + provider_->name() + ")"; + } + return "LLMRunnable"; +} + +void LLMRunnable::invoke(const JsonValue& input, + const RunnableConfig& /* config */, + Dispatcher& dispatcher, + Callback callback) { + // Validate provider + if (!provider_) { + postError(dispatcher, std::move(callback), LLMError::UNKNOWN, + "No LLM provider configured"); + return; + } + + // Parse input + ParsedInput parsed = parseInput(input); + + // Validate messages + if (parsed.messages.empty()) { + postError(dispatcher, std::move(callback), + LLMError::INVALID_MODEL, "No messages provided"); + return; + } + + // Call the LLM provider + provider_->chat( + parsed.messages, parsed.tools, parsed.config, dispatcher, + [callback = std::move(callback)](Result result) mutable { + if (mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + } else { + JsonValue output = responseToJson(mcp::get(result)); + callback(Result(std::move(output))); + } + }); +} + +// ============================================================================= +// Input Parsing +// ============================================================================= + +LLMRunnable::ParsedInput LLMRunnable::parseInput(const JsonValue& input) const { + ParsedInput result; + result.config = default_config_; + + // Handle string input as simple user message + if (input.isString()) { + result.messages.push_back(Message::user(input.getString())); + return result; + } + + // Handle object input + if (!input.isObject()) { + return result; + } + + // Parse messages array + if (input.contains("messages") && input["messages"].isArray()) { + const auto& messages_array = input["messages"]; + for (size_t i = 0; i < messages_array.size(); ++i) { + result.messages.push_back(parseMessage(messages_array[i])); + } + } + + // Parse tools array + if (input.contains("tools") && input["tools"].isArray()) { + const auto& tools_array = input["tools"]; + for (size_t i = 0; i < tools_array.size(); ++i) { + result.tools.push_back(parseToolSpec(tools_array[i])); + } + } + + // Parse config overrides + if (input.contains("config") && input["config"].isObject()) { + const auto& config_obj = input["config"]; + + if (config_obj.contains("model") && config_obj["model"].isString()) { + result.config.model = config_obj["model"].getString(); + } + if (config_obj.contains("temperature") && + config_obj["temperature"].isNumber()) { + result.config.temperature = config_obj["temperature"].getFloat(); + } + if (config_obj.contains("max_tokens") && + config_obj["max_tokens"].isNumber()) { + result.config.max_tokens = config_obj["max_tokens"].getInt(); + } + if (config_obj.contains("top_p") && config_obj["top_p"].isNumber()) { + result.config.top_p = config_obj["top_p"].getFloat(); + } + if (config_obj.contains("seed") && config_obj["seed"].isNumber()) { + result.config.seed = config_obj["seed"].getInt(); + } + } + + return result; +} + +Message LLMRunnable::parseMessage(const JsonValue& json) { + if (!json.isObject()) { + return Message::user(""); + } + + Role role = Role::USER; + if (json.contains("role") && json["role"].isString()) { + role = parseRole(json["role"].getString()); + } + + std::string content; + if (json.contains("content") && json["content"].isString()) { + content = json["content"].getString(); + } + + Message msg(role, content); + + // Parse tool_call_id for tool messages + if (json.contains("tool_call_id") && json["tool_call_id"].isString()) { + msg.tool_call_id = json["tool_call_id"].getString(); + } + + // Parse tool_calls for assistant messages + if (json.contains("tool_calls") && json["tool_calls"].isArray()) { + std::vector calls; + const auto& calls_array = json["tool_calls"]; + for (size_t i = 0; i < calls_array.size(); ++i) { + const auto& call_obj = calls_array[i]; + if (call_obj.isObject()) { + ToolCall call; + if (call_obj.contains("id") && call_obj["id"].isString()) { + call.id = call_obj["id"].getString(); + } + if (call_obj.contains("name") && call_obj["name"].isString()) { + call.name = call_obj["name"].getString(); + } + if (call_obj.contains("arguments")) { + call.arguments = call_obj["arguments"]; + } + calls.push_back(std::move(call)); + } + } + if (!calls.empty()) { + msg.tool_calls = std::move(calls); + } + } + + return msg; +} + +ToolSpec LLMRunnable::parseToolSpec(const JsonValue& json) { + ToolSpec spec; + if (!json.isObject()) { + return spec; + } + + if (json.contains("name") && json["name"].isString()) { + spec.name = json["name"].getString(); + } + if (json.contains("description") && json["description"].isString()) { + spec.description = json["description"].getString(); + } + if (json.contains("parameters")) { + spec.parameters = json["parameters"]; + } + + return spec; +} + +// ============================================================================= +// Output Conversion +// ============================================================================= + +JsonValue LLMRunnable::responseToJson(const LLMResponse& response) { + JsonValue output = JsonValue::object(); + + // Convert message + output["message"] = messageToJson(response.message); + + // Add finish_reason + output["finish_reason"] = response.finish_reason; + + // Add usage if present + if (response.usage.has_value()) { + JsonValue usage = JsonValue::object(); + usage["prompt_tokens"] = response.usage->prompt_tokens; + usage["completion_tokens"] = response.usage->completion_tokens; + usage["total_tokens"] = response.usage->total_tokens; + output["usage"] = usage; + } + + return output; +} + +JsonValue LLMRunnable::messageToJson(const Message& message) { + JsonValue json = JsonValue::object(); + + json["role"] = roleToString(message.role); + json["content"] = message.content; + + if (message.tool_call_id.has_value()) { + json["tool_call_id"] = *message.tool_call_id; + } + + if (message.tool_calls.has_value() && !message.tool_calls->empty()) { + JsonValue calls_array = JsonValue::array(); + for (const auto& call : *message.tool_calls) { + JsonValue call_obj = JsonValue::object(); + call_obj["id"] = call.id; + call_obj["name"] = call.name; + call_obj["arguments"] = call.arguments; + calls_array.push_back(call_obj); + } + json["tool_calls"] = calls_array; + } + + return json; +} + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/llm/openai_provider.cc b/third_party/gopher-orch/src/gopher/orch/llm/openai_provider.cc new file mode 100644 index 00000000..894ba49b --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/llm/openai_provider.cc @@ -0,0 +1,412 @@ +// OpenAI Provider Implementation + +#include "gopher/orch/llm/openai_provider.h" + +#include +#include + +#include "gopher/orch/server/rest_server.h" + +namespace gopher { +namespace orch { +namespace llm { + +using namespace gopher::orch::core; +using namespace gopher::orch::server; + +// ═══════════════════════════════════════════════════════════════════════════ +// IMPLEMENTATION +// ═══════════════════════════════════════════════════════════════════════════ + +class OpenAIProvider::Impl { + public: + OpenAIConfig config; + HttpClientPtr http_client; + mutable std::mutex mutex; + + explicit Impl(const OpenAIConfig& cfg) : config(cfg) { + // Use CurlHttpClient for real HTTP requests + http_client = server::createCurlHttpClient(); + } + + std::string chatEndpoint() const { + if (config.is_azure) { + return config.base_url + "/openai/deployments/" + + config.azure_deployment + + "/chat/completions?api-version=" + config.azure_api_version; + } + return config.base_url + "/chat/completions"; + } + + std::map headers() const { + std::map hdrs; + hdrs["Content-Type"] = "application/json"; + + if (config.is_azure) { + hdrs["api-key"] = config.api_key; + } else { + hdrs["Authorization"] = "Bearer " + config.api_key; + if (!config.organization.empty()) { + hdrs["OpenAI-Organization"] = config.organization; + } + } + + return hdrs; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// FACTORY METHODS +// ═══════════════════════════════════════════════════════════════════════════ + +OpenAIProvider::Ptr OpenAIProvider::create(const std::string& api_key) { + return create(OpenAIConfig(api_key)); +} + +OpenAIProvider::Ptr OpenAIProvider::create(const std::string& api_key, + const std::string& base_url) { + OpenAIConfig config(api_key); + if (!base_url.empty()) { + config.withBaseUrl(base_url); + } + return create(config); +} + +OpenAIProvider::Ptr OpenAIProvider::create(const OpenAIConfig& config) { + return Ptr(new OpenAIProvider(config)); +} + +OpenAIProvider::OpenAIProvider(const OpenAIConfig& config) + : impl_(std::make_unique(config)) {} + +OpenAIProvider::~OpenAIProvider() = default; + +// ═══════════════════════════════════════════════════════════════════════════ +// CHAT COMPLETION +// ═══════════════════════════════════════════════════════════════════════════ + +void OpenAIProvider::chat(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) { + // Build request + auto request = buildRequest(messages, tools, config, false); + auto request_body = request.toString(); + + auto url = impl_->chatEndpoint(); + auto headers = impl_->headers(); + + // Make HTTP request + impl_->http_client->request( + HttpMethod::POST, url, headers, request_body, dispatcher, + [this, callback = std::move(callback)](Result result) { + if (!mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + return; + } + + auto& response = mcp::get(result); + if (!response.isSuccess()) { + // Parse error response + std::string error_msg = + "HTTP " + std::to_string(response.status_code); + try { + auto error_json = JsonValue::parse(response.body); + if (error_json.contains("error") && + error_json["error"].contains("message")) { + error_msg = error_json["error"]["message"].getString(); + } + } catch (...) { + error_msg += ": " + response.body; + } + + int error_code = LLMError::UNKNOWN; + if (response.status_code == 401) { + error_code = LLMError::INVALID_API_KEY; + } else if (response.status_code == 429) { + error_code = LLMError::RATE_LIMITED; + } else if (response.status_code >= 500) { + error_code = LLMError::SERVICE_UNAVAILABLE; + } + + callback(Result(Error(error_code, error_msg))); + return; + } + + // Parse response + try { + auto response_json = JsonValue::parse(response.body); + auto parsed = parseResponse(response_json); + callback(std::move(parsed)); + } catch (const std::exception& e) { + callback(Result( + Error(LLMError::PARSE_ERROR, + std::string("Failed to parse response: ") + e.what()))); + } + }); +} + +void OpenAIProvider::chatStream(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + StreamCallback on_chunk, + ChatCallback on_complete) { + // For now, fall back to non-streaming + // Full streaming implementation would require SSE parsing + chat(messages, tools, config, dispatcher, std::move(on_complete)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// REQUEST/RESPONSE BUILDING +// ═══════════════════════════════════════════════════════════════════════════ + +JsonValue OpenAIProvider::buildRequest(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + bool stream) const { + JsonValue request = JsonValue::object(); + + // Model + request["model"] = config.model; + + // Messages + JsonValue msgs = JsonValue::array(); + for (const auto& msg : messages) { + msgs.push_back(messageToJson(msg)); + } + request["messages"] = msgs; + + // Tools (if any) + if (!tools.empty()) { + JsonValue tool_array = JsonValue::array(); + for (const auto& tool : tools) { + tool_array.push_back(toolToJson(tool)); + } + request["tools"] = tool_array; + } + + // Optional parameters + if (config.temperature.has_value()) { + request["temperature"] = *config.temperature; + } + if (config.max_tokens.has_value()) { + request["max_tokens"] = *config.max_tokens; + } + if (config.top_p.has_value()) { + request["top_p"] = *config.top_p; + } + if (config.seed.has_value()) { + request["seed"] = *config.seed; + } + if (config.stop.has_value() && !config.stop->empty()) { + JsonValue stop_array = JsonValue::array(); + for (const auto& s : *config.stop) { + stop_array.push_back(s); + } + request["stop"] = stop_array; + } + + if (stream) { + request["stream"] = true; + } + + return request; +} + +Result OpenAIProvider::parseResponse( + const JsonValue& response) const { + LLMResponse result; + + try { + // Get the first choice + if (!response.contains("choices") || response["choices"].empty()) { + return Result( + Error(LLMError::PARSE_ERROR, "No choices in response")); + } + + const auto& choice = response["choices"][0]; + + // Parse finish reason + if (choice.contains("finish_reason") && !choice["finish_reason"].isNull()) { + result.finish_reason = choice["finish_reason"].getString(); + } + + // Parse message + if (choice.contains("message")) { + const auto& msg = choice["message"]; + + // Role + if (msg.contains("role")) { + result.message.role = parseRole(msg["role"].getString()); + } else { + result.message.role = Role::ASSISTANT; + } + + // Content + if (msg.contains("content") && !msg["content"].isNull()) { + result.message.content = msg["content"].getString(); + } + + // Tool calls + if (msg.contains("tool_calls") && !msg["tool_calls"].isNull()) { + std::vector tool_calls; + for (size_t i = 0; i < msg["tool_calls"].size(); ++i) { + const auto& tc = msg["tool_calls"][i]; + ToolCall call; + call.id = tc["id"].getString(); + + if (tc.contains("function")) { + call.name = tc["function"]["name"].getString(); + if (tc["function"].contains("arguments")) { + std::string args_str = tc["function"]["arguments"].getString(); + try { + call.arguments = JsonValue::parse(args_str); + } catch (...) { + // If parsing fails, store as string + call.arguments = args_str; + } + } + } + + tool_calls.push_back(std::move(call)); + } + result.message.tool_calls = std::move(tool_calls); + } + } + + // Parse usage + if (response.contains("usage")) { + const auto& usage = response["usage"]; + Usage u; + u.prompt_tokens = + usage.contains("prompt_tokens") ? usage["prompt_tokens"].getInt() : 0; + u.completion_tokens = usage.contains("completion_tokens") + ? usage["completion_tokens"].getInt() + : 0; + u.total_tokens = + usage.contains("total_tokens") ? usage["total_tokens"].getInt() : 0; + result.usage = u; + } + + return Result(std::move(result)); + + } catch (const std::exception& e) { + return Result( + Error(LLMError::PARSE_ERROR, std::string("Parse error: ") + e.what())); + } +} + +JsonValue OpenAIProvider::messageToJson(const Message& msg) const { + JsonValue json = JsonValue::object(); + + json["role"] = roleToString(msg.role); + + // Handle tool results + if (msg.role == Role::TOOL) { + json["role"] = "tool"; + json["content"] = msg.content; + if (msg.tool_call_id.has_value()) { + json["tool_call_id"] = *msg.tool_call_id; + } + return json; + } + + // Regular message content + if (!msg.content.empty()) { + json["content"] = msg.content; + } + + // Tool calls for assistant messages + if (msg.role == Role::ASSISTANT && msg.hasToolCalls()) { + JsonValue tool_calls = JsonValue::array(); + for (const auto& tc : *msg.tool_calls) { + JsonValue call = JsonValue::object(); + call["id"] = tc.id; + call["type"] = "function"; + + JsonValue func = JsonValue::object(); + func["name"] = tc.name; + func["arguments"] = tc.arguments.toString(); + call["function"] = func; + + tool_calls.push_back(call); + } + json["tool_calls"] = tool_calls; + } + + return json; +} + +JsonValue OpenAIProvider::toolToJson(const ToolSpec& tool) const { + JsonValue json = JsonValue::object(); + json["type"] = "function"; + + JsonValue func = JsonValue::object(); + func["name"] = tool.name; + func["description"] = tool.description; + func["parameters"] = tool.parameters; + + json["function"] = func; + return json; +} + +Result OpenAIProvider::parseStreamChunk( + const std::string& data) const { + // SSE data parsing would go here + // For now, return empty chunk + StreamChunk chunk; + return Result(std::move(chunk)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MODEL SUPPORT +// ═══════════════════════════════════════════════════════════════════════════ + +bool OpenAIProvider::isModelSupported(const std::string& model) const { + // Accept any model string - OpenAI will validate + // This allows for new models and custom deployments + return !model.empty(); +} + +std::vector OpenAIProvider::supportedModels() const { + return {"gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", + "gpt-3.5-turbo", "o1", "o1-mini", "o1-preview"}; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════ + +std::string OpenAIProvider::endpoint() const { return impl_->chatEndpoint(); } + +bool OpenAIProvider::isConfigured() const { + return !impl_->config.api_key.empty(); +} + +std::string OpenAIProvider::organization() const { + std::lock_guard lock(impl_->mutex); + return impl_->config.organization; +} + +void OpenAIProvider::setOrganization(const std::string& org) { + std::lock_guard lock(impl_->mutex); + impl_->config.organization = org; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// FACTORY FUNCTION +// ═══════════════════════════════════════════════════════════════════════════ + +LLMProviderPtr createOpenAIProvider(const std::string& api_key, + const std::string& base_url) { + if (base_url.empty()) { + return OpenAIProvider::create(api_key); + } + return OpenAIProvider::create(api_key, base_url); +} + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/server/curl_http_client.cc b/third_party/gopher-orch/src/gopher/orch/server/curl_http_client.cc new file mode 100644 index 00000000..40e47f0c --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/server/curl_http_client.cc @@ -0,0 +1,300 @@ +// Curl-based HTTP Client Implementation + +#include "gopher/orch/server/rest_server.h" +#include +#include +#include +#include + +namespace gopher { +namespace orch { +namespace server { + +class CurlHttpClient : public HttpClient { + public: + CurlHttpClient() { + // Initialize curl globally (thread-safe) + curl_global_init(CURL_GLOBAL_ALL); + } + + ~CurlHttpClient() override { + curl_global_cleanup(); + } + + // Utility function for synchronous JSON GET requests + std::string fetchJson(const std::string& url, + const std::map& headers = {}) { + return performSyncRequest(HttpMethod::GET, url, headers, ""); + } + + void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + ResponseCallback callback) override { + + // Perform request in a separate thread to avoid blocking + std::thread([method, url, headers, body, &dispatcher, callback]() { + CURL* curl = curl_easy_init(); + if (!curl) { + dispatcher.post([callback]() { + callback(Result( + Error(OrchError::INTERNAL_ERROR, "Failed to initialize CURL"))); + }); + return; + } + + // Response data + std::string response_body; + std::string response_headers; + + // Set URL + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + + // Set method + switch (method) { + case HttpMethod::GET: + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + break; + case HttpMethod::POST: + curl_easy_setopt(curl, CURLOPT_POST, 1L); + break; + case HttpMethod::PUT: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + break; + case HttpMethod::DELETE_: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + break; + case HttpMethod::PATCH: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH"); + break; + case HttpMethod::HEAD: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "HEAD"); + curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); + break; + case HttpMethod::OPTIONS: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "OPTIONS"); + break; + } + + // Set headers + struct curl_slist* header_list = nullptr; + for (const auto& header : headers) { + std::string header_str = header.first + ": " + header.second; + header_list = curl_slist_append(header_list, header_str.c_str()); + } + if (header_list) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list); + } + + // Set request body + if (!body.empty()) { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, body.size()); + } + + // Set write callbacks + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, headerCallback); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response_headers); + + // Set timeout + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + // Enable following redirects + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + + // Perform the request + CURLcode res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + std::string error_msg = "CURL error: "; + error_msg += curl_easy_strerror(res); + curl_easy_cleanup(curl); + if (header_list) { + curl_slist_free_all(header_list); + } + + dispatcher.post([callback, error_msg]() { + callback(Result( + Error(OrchError::INTERNAL_ERROR, error_msg))); + }); + return; + } + + // Get status code + long status_code; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code); + + // Parse response headers + std::map response_header_map; + std::istringstream header_stream(response_headers); + std::string line; + while (std::getline(header_stream, line)) { + size_t colon_pos = line.find(':'); + if (colon_pos != std::string::npos) { + std::string key = line.substr(0, colon_pos); + std::string value = line.substr(colon_pos + 1); + // Trim whitespace + while (!key.empty() && std::isspace(key.back())) key.pop_back(); + while (!value.empty() && std::isspace(value.front())) value.erase(0, 1); + if (!key.empty()) { + response_header_map[key] = value; + } + } + } + + // Clean up + curl_easy_cleanup(curl); + if (header_list) { + curl_slist_free_all(header_list); + } + + // Create response + HttpResponse response; + response.status_code = static_cast(status_code); + response.headers = std::move(response_header_map); + response.body = std::move(response_body); + + dispatcher.post([callback, response]() { + callback(core::makeSuccess(response)); + }); + }).detach(); + } + + private: + // Synchronous request helper for fetchJson + std::string performSyncRequest(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body) { + CURL* curl = curl_easy_init(); + if (!curl) { + throw std::runtime_error("Failed to initialize CURL"); + } + + // Response data + std::string response_body; + + // Set URL + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + + // Set method + switch (method) { + case HttpMethod::GET: + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + break; + case HttpMethod::POST: + curl_easy_setopt(curl, CURLOPT_POST, 1L); + break; + case HttpMethod::PUT: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + break; + case HttpMethod::DELETE_: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + break; + case HttpMethod::PATCH: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH"); + break; + case HttpMethod::HEAD: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "HEAD"); + curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); + break; + case HttpMethod::OPTIONS: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "OPTIONS"); + break; + } + + // Set headers + struct curl_slist* header_list = nullptr; + for (const auto& header : headers) { + std::string header_str = header.first + ": " + header.second; + header_list = curl_slist_append(header_list, header_str.c_str()); + } + if (header_list) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list); + } + + // Set request body + if (!body.empty()) { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, body.size()); + } + + // Set write callback + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); + + // Set timeout + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + // Enable following redirects + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + + // SSL options + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + // Perform the request + CURLcode res = curl_easy_perform(curl); + + if (res != CURLE_OK) { + std::string error_msg = "HTTP request failed: "; + error_msg += curl_easy_strerror(res); + curl_easy_cleanup(curl); + if (header_list) { + curl_slist_free_all(header_list); + } + throw std::runtime_error(error_msg); + } + + // Get status code + long status_code; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code); + + // Clean up + curl_easy_cleanup(curl); + if (header_list) { + curl_slist_free_all(header_list); + } + + // Check for HTTP error status + if (status_code >= 400) { + throw std::runtime_error("HTTP request failed with status " + std::to_string(status_code)); + } + + return response_body; + } + + static size_t writeCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t total_size = size * nmemb; + std::string* str = static_cast(userp); + str->append(static_cast(contents), total_size); + return total_size; + } + + static size_t headerCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t total_size = size * nmemb; + std::string* str = static_cast(userp); + str->append(static_cast(contents), total_size); + return total_size; + } +}; + +// Factory function to create CurlHttpClient +std::shared_ptr createCurlHttpClient() { + return std::make_shared(); +} + +// Utility function for making synchronous JSON HTTP GET requests +std::string fetchJsonSync(const std::string& url, + const std::map& headers) { + CurlHttpClient client; + return client.fetchJson(url, headers); +} + +} // namespace server +} // namespace orch +} // namespace gopher \ No newline at end of file diff --git a/third_party/gopher-orch/src/gopher/orch/server/gateway_server.cpp b/third_party/gopher-orch/src/gopher/orch/server/gateway_server.cpp new file mode 100644 index 00000000..54fecce2 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/server/gateway_server.cpp @@ -0,0 +1,749 @@ +/** + * @file gateway_server.cpp + * @brief Implementation of GatewayServer - MCP server exposing ServerComposite tools + */ + +#include "gopher/orch/server/gateway_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/server/mcp_server.h" +#include "mcp/event/libevent_dispatcher.h" +#include "mcp/logging/log_macros.h" +#include "nlohmann/json.hpp" + +// Define log component for this file +#undef GOPHER_LOG_COMPONENT +#define GOPHER_LOG_COMPONENT "Gateway" + +namespace gopher { +namespace orch { +namespace server { + +// Global signal handling for graceful shutdown +namespace { + volatile sig_atomic_t g_shutdown_requested = 0; + volatile sig_atomic_t g_signal_count = 0; + mcp::server::McpServer* g_mcp_server = nullptr; + + void signal_handler(int signal) { + if (signal == SIGINT || signal == SIGTERM) { + g_shutdown_requested = 1; + g_signal_count++; + + // Second Ctrl+C forces immediate exit + if (g_signal_count >= 2) { + const char msg[] = "\nForced exit.\n"; + write(STDERR_FILENO, msg, sizeof(msg) - 1); + _exit(1); + } + + // Try graceful shutdown + if (g_mcp_server) { + g_mcp_server->shutdown(); + } + } + } +} + +// Private constructor for JSON-based creation +GatewayServer::GatewayServer(const GatewayServerConfig& config) + : config_(config) { + // Composite will be created by initFromJson() +} + +GatewayServer::GatewayServer(ServerCompositePtr composite, + const GatewayServerConfig& config) + : composite_(std::move(composite)), config_(config) { + // Configure MCP server + mcp::server::McpServerConfig mcp_config; + mcp_config.server_name = config_.name; + mcp_config.server_version = "1.0.0"; + mcp_config.protocol_version = "2024-11-05"; + + // Transport configuration + mcp_config.supported_transports = {mcp::TransportType::HttpSse}; + + // HTTP/SSE paths + mcp_config.http_rpc_path = config_.http_rpc_path; + mcp_config.http_sse_path = config_.http_sse_path; + mcp_config.http_health_path = config_.http_health_path; + + // Session management + mcp_config.max_sessions = config_.max_sessions; + mcp_config.session_timeout = config_.session_timeout; + + // Create MCP server + mcp_server_ = std::make_unique(mcp_config); +} + +GatewayServer::~GatewayServer() { + // Stop the backend dispatcher thread + if (dispatcher_running_) { + dispatcher_running_ = false; + if (dispatcher_thread_ && dispatcher_thread_->joinable()) { + dispatcher_thread_->join(); + } + } + + if (running_.load()) { + // Force shutdown + if (mcp_server_) { + mcp_server_->shutdown(); + } + running_ = false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SIMPLE API IMPLEMENTATION +// ═══════════════════════════════════════════════════════════════════════════ + +GatewayServer::Ptr GatewayServer::create(const std::string& serverJson, + const GatewayServerConfig& config) { + auto gateway = std::shared_ptr(new GatewayServer(config)); + + if (!gateway->initFromJson(serverJson)) { + // Error message is set in initFromJson + return gateway; // Return with error set + } + + return gateway; +} + +bool GatewayServer::initFromJson(const std::string& serverJson) { + try { + auto json = nlohmann::json::parse(serverJson); + + // Check for API response format (with succeeded, code, message, data) + if (json.contains("succeeded") && !json["succeeded"].get()) { + error_message_ = "Server configuration indicates failure"; + if (json.contains("message")) { + error_message_ = json["message"].get(); + } + return false; + } + + // Extract manifest from data (API response) or root (config file) + nlohmann::json manifest; + if (json.contains("data") && json["data"].is_object()) { + // API response format: { succeeded, code, message, data: { version, metadata, config, servers } } + manifest = json["data"]; + } else if (json.contains("servers")) { + // Direct manifest format: { version, metadata, config, servers } + manifest = json; + } else { + error_message_ = "No 'servers' array found in configuration"; + return false; + } + + // Extract servers array + if (!manifest.contains("servers")) { + error_message_ = "No 'servers' array found in configuration"; + return false; + } + nlohmann::json servers_array = manifest["servers"]; + + if (!servers_array.is_array() || servers_array.empty()) { + error_message_ = "Servers array is empty or invalid"; + return false; + } + + // Parse config object (connectTimeout, requestTimeout, retryPolicy) + // Default values + int connect_timeout_ms = 5000; + int request_timeout_ms = 30000; + + if (manifest.contains("config") && manifest["config"].is_object()) { + auto& config_obj = manifest["config"]; + connect_timeout_ms = config_obj.value("connectTimeout", 5000); + request_timeout_ms = config_obj.value("requestTimeout", 30000); + + // Parse retry policy (for logging/future use) + if (config_obj.contains("retryPolicy") && config_obj["retryPolicy"].is_object()) { + auto& retry = config_obj["retryPolicy"]; + int max_attempts = retry.value("maxAttempts", 5); + int initial_backoff = retry.value("initialBackoff", 1000); + double backoff_multiplier = retry.value("backoffMultiplier", 2.0); + int max_backoff = retry.value("maxBackoff", 30000); + double jitter = retry.value("jitter", 0.2); + + } + } else { + // Legacy format: timeouts at root level + connect_timeout_ms = manifest.value("connectTimeout", 5000); + request_timeout_ms = manifest.value("requestTimeout", 30000); + } + + // Create composite + composite_ = ServerComposite::create("gateway-composite"); + + // Create dispatcher for server connections + owned_dispatcher_ = std::make_unique("gateway_init"); + + // Parse and connect to each server + for (const auto& server_obj : servers_array) { + std::string name = server_obj.value("name", "unnamed"); + std::string server_id = server_obj.value("serverId", ""); + + // New format: url is directly on server object + // Also support legacy format: url in config.url + std::string url; + std::string command; + + if (server_obj.contains("url")) { + // New format: direct url field + url = server_obj.value("url", ""); + } else if (server_obj.contains("config")) { + // Legacy format: nested config object + auto server_config = server_obj["config"]; + url = server_config.value("url", ""); + command = server_config.value("command", ""); + } + + // Auto-detect transport type based on available fields + bool is_http = !url.empty(); + bool is_stdio = !command.empty() || server_obj.value("command", "").length() > 0; + + if (!is_http && !is_stdio) { + std::cerr << "Warning: Server '" << name << "' has no url or command, skipping\n"; + continue; + } + + // Create MCPServerConfig + MCPServerConfig mcp_server_config; + mcp_server_config.name = name; + + // Use per-server timeouts if specified, otherwise fall back to global config + int server_connect_timeout = server_obj.value("connectTimeout", connect_timeout_ms); + int server_request_timeout = server_obj.value("requestTimeout", request_timeout_ms); + + mcp_server_config.connect_timeout = std::chrono::milliseconds(server_connect_timeout); + mcp_server_config.request_timeout = std::chrono::milliseconds(server_request_timeout); + + if (is_http) { + // HTTP/SSE transport (auto-negotiates SSE vs Streamable HTTP) + mcp_server_config.transport_type = MCPServerConfig::TransportType::HTTP_SSE; + mcp_server_config.http_sse_transport.url = url; + } else if (is_stdio) { + // Stdio transport + mcp_server_config.transport_type = MCPServerConfig::TransportType::STDIO; + // Check both direct and legacy nested config + if (server_obj.contains("command")) { + mcp_server_config.stdio_transport.command = server_obj.value("command", ""); + } else if (server_obj.contains("config")) { + auto server_config = server_obj["config"]; + mcp_server_config.stdio_transport.command = server_config.value("command", ""); + if (server_config.contains("args") && server_config["args"].is_array()) { + for (const auto& arg : server_config["args"]) { + mcp_server_config.stdio_transport.args.push_back(arg.get()); + } + } + } + } + + // Create and connect synchronously + std::mutex connect_mutex; + std::condition_variable connect_cv; + bool connect_done = false; + bool connect_success = false; + std::string connect_error; + MCPServerPtr server; + + MCPServer::create(mcp_server_config, *owned_dispatcher_, + [&](Result result) { + std::lock_guard lock(connect_mutex); + if (mcp::holds_alternative(result)) { + server = mcp::get(result); + connect_success = true; + } else { + connect_success = false; + connect_error = mcp::get(result).message; + } + connect_done = true; + connect_cv.notify_one(); + }); + + // Run dispatcher until connect completes + std::thread dispatcher_thread([this, &connect_done]() { + while (!connect_done) { + owned_dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + }); + + { + std::unique_lock lock(connect_mutex); + connect_cv.wait_for(lock, std::chrono::seconds(30), + [&connect_done]() { return connect_done; }); + } + + dispatcher_thread.join(); + + if (!connect_success || !server) { + std::cerr << "Warning: Failed to connect to server '" << name << "': " << connect_error << "\n"; + continue; + } + + // Get tools and add to composite + std::mutex tools_mutex; + std::condition_variable tools_cv; + bool tools_done = false; + std::vector server_tools; + + server->listTools(*owned_dispatcher_, + [&](Result> result) { + std::lock_guard lock(tools_mutex); + if (!isError>(result)) { + server_tools = mcp::get>(result); + } + tools_done = true; + tools_cv.notify_one(); + }); + + std::thread tools_thread([this, &tools_done]() { + while (!tools_done) { + owned_dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + }); + + { + std::unique_lock lock(tools_mutex); + tools_cv.wait_for(lock, std::chrono::seconds(10), + [&tools_done]() { return tools_done; }); + } + + tools_thread.join(); + + // Add server to composite and cache tool info + std::vector tool_names; + for (const auto& tool : server_tools) { + tool_names.push_back(tool.name); + // Cache full tool info for later registration + cached_tool_infos_.push_back(tool); + } + + composite_->addServer(server, tool_names, false); + std::cout << "Connected to '" << name << "' with " << tool_names.size() << " tools\n"; + } + + if (composite_->servers().empty()) { + error_message_ = "No servers connected successfully"; + return false; + } + + // Initialize MCP server configuration + mcp::server::McpServerConfig mcp_config; + mcp_config.server_name = config_.name; + mcp_config.server_version = "1.0.0"; + mcp_config.protocol_version = "2024-11-05"; + mcp_config.supported_transports = {mcp::TransportType::HttpSse}; + mcp_config.http_rpc_path = config_.http_rpc_path; + mcp_config.http_sse_path = config_.http_sse_path; + mcp_config.http_health_path = config_.http_health_path; + mcp_config.max_sessions = config_.max_sessions; + mcp_config.session_timeout = config_.session_timeout; + + mcp_server_ = std::make_unique(mcp_config); + + // Start background thread to keep backend dispatcher running + // This is necessary because the MCP clients need their event loop running + // to process responses from backend servers + dispatcher_running_ = true; + dispatcher_thread_ = std::make_unique([this]() { + GOPHER_LOG_DEBUG("Backend dispatcher thread started"); + while (dispatcher_running_) { + owned_dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + GOPHER_LOG_DEBUG("Backend dispatcher thread stopped"); + }); + + return true; + + } catch (const nlohmann::json::exception& e) { + error_message_ = std::string("JSON parse error: ") + e.what(); + return false; + } catch (const std::exception& e) { + error_message_ = std::string("Error: ") + e.what(); + return false; + } +} + +int GatewayServer::listen(int port) { + if (!composite_ || !mcp_server_) { + std::cerr << "Error: Gateway not properly initialized"; + if (!error_message_.empty()) { + std::cerr << ": " << error_message_; + } + std::cerr << "\n"; + return 1; + } + + // Override port if specified + if (port > 0) { + config_.port = port; + } + + // Set up signal handling + g_mcp_server = mcp_server_.get(); + g_shutdown_requested = 0; + g_signal_count = 0; + std::signal(SIGINT, signal_handler); + std::signal(SIGTERM, signal_handler); + + // Register tools from composite + registerToolsFromComposite(); + + // Register ping handler + mcp_server_->registerRequestHandler( + "ping", + [](const mcp::jsonrpc::Request& request, + mcp::server::SessionContext& session) { + auto pong = mcp::make() + .add("pong", true) + .add("timestamp", static_cast(std::time(nullptr))) + .build(); + return mcp::jsonrpc::Response::success( + request.id, mcp::jsonrpc::ResponseResult(pong)); + }); + + // Start listening (MCP server requires http:// prefix for HTTP transport) + std::string listen_url = getListenUrl(); + std::string listen_address = getListenAddress(); + auto listen_result = mcp_server_->listen(listen_url); + + if (mcp::is_error(listen_result)) { + auto error = mcp::get_error(listen_result); + std::cerr << "Failed to start gateway: " << error->message << "\n"; + g_mcp_server = nullptr; + return 1; + } + + running_ = true; + + std::cout << "\n"; + std::cout << "╔════════════════════════════════════════════════════════════╗\n"; + std::cout << "║ Gateway Server Running ║\n"; + std::cout << "╠════════════════════════════════════════════════════════════╣\n"; + std::cout << "║ Address: " << listen_address; + for (size_t i = listen_address.length(); i < 49; ++i) std::cout << " "; + std::cout << "║\n"; + std::cout << "║ Tools: " << tool_count_.load(); + std::string tools_str = std::to_string(tool_count_.load()); + for (size_t i = tools_str.length(); i < 49; ++i) std::cout << " "; + std::cout << "║\n"; + std::cout << "║ Servers: " << serverCount(); + std::string servers_str = std::to_string(serverCount()); + for (size_t i = servers_str.length(); i < 49; ++i) std::cout << " "; + std::cout << "║\n"; + std::cout << "╠════════════════════════════════════════════════════════════╣\n"; + std::cout << "║ Endpoints: ║\n"; + std::cout << "║ GET /health - Health check ║\n"; + std::cout << "║ GET /info - Server info ║\n"; + std::cout << "║ POST /mcp - JSON-RPC endpoint ║\n"; + std::cout << "║ GET /events - SSE event stream ║\n"; + std::cout << "╠════════════════════════════════════════════════════════════╣\n"; + std::cout << "║ List tools: curl -X POST http://" << listen_address << "/mcp"; + for (size_t i = listen_address.length(); i < 22; ++i) std::cout << " "; + std::cout << "║\n"; + std::cout << "║ -H 'Content-Type: application/json' ║\n"; + std::cout << "║ -d '{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":1}' ║\n"; + std::cout << "╠════════════════════════════════════════════════════════════╣\n"; + std::cout << "║ Press Ctrl+C to stop ║\n"; + std::cout << "╚════════════════════════════════════════════════════════════╝\n"; + std::cout << "\n"; + + // Run the server (blocks until shutdown) + mcp_server_->run(); + + running_ = false; + g_mcp_server = nullptr; + + std::cout << "\nGateway server stopped.\n"; + return 0; +} + +void GatewayServer::stop() { + shutdown_requested_ = true; + + // Stop the backend dispatcher thread + if (dispatcher_running_) { + dispatcher_running_ = false; + if (dispatcher_thread_ && dispatcher_thread_->joinable()) { + dispatcher_thread_->join(); + } + } + + if (mcp_server_ && running_.load()) { + mcp_server_->shutdown(); + running_ = false; + } +} + +size_t GatewayServer::serverCount() const { + return composite_ ? composite_->servers().size() : 0; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ADVANCED API IMPLEMENTATION +// ═══════════════════════════════════════════════════════════════════════════ + +void GatewayServer::start(Dispatcher& dispatcher, + std::function callback) { + if (running_.load()) { + dispatcher.post([callback]() { + callback(VoidResult(Error(-1, "Gateway server already running"))); + }); + return; + } + + // Register tools from composite + registerToolsFromComposite(); + + // Register ping handler for client keep-alive + mcp_server_->registerRequestHandler( + "ping", + [](const mcp::jsonrpc::Request& request, + mcp::server::SessionContext& session) { + auto pong = mcp::make() + .add("pong", true) + .add("timestamp", static_cast(std::time(nullptr))) + .build(); + return mcp::jsonrpc::Response::success( + request.id, mcp::jsonrpc::ResponseResult(pong)); + }); + + // Start listening (MCP server requires http:// prefix for HTTP transport) + std::string listen_url = getListenUrl(); + auto listen_result = mcp_server_->listen(listen_url); + + if (mcp::is_error(listen_result)) { + auto error = mcp::get_error(listen_result); + dispatcher.post([callback, error]() { + callback(VoidResult(Error(-1, "Failed to start gateway: " + error->message))); + }); + return; + } + + running_ = true; + + // Start the server in a separate thread + std::thread server_thread([this]() { + mcp_server_->run(); + }); + server_thread.detach(); + + dispatcher.post([callback]() { callback(VoidResult(nullptr)); }); +} + +void GatewayServer::stop(Dispatcher& dispatcher, + std::function callback) { + if (!running_.load()) { + if (callback) { + dispatcher.post(callback); + } + return; + } + + mcp_server_->shutdown(); + running_ = false; + + if (callback) { + dispatcher.post(callback); + } +} + +void GatewayServer::registerToolsFromComposite() { + if (!composite_) { + return; + } + + // Use cached tool infos (with descriptions and schemas) if available, + // otherwise fall back to composite's list (names only) + const auto& tool_infos = cached_tool_infos_.empty() + ? composite_->listToolInfos() + : cached_tool_infos_; + size_t count = 0; + + for (const auto& info : tool_infos) { + // Create MCP Tool from ServerToolInfo + mcp::Tool mcp_tool; + mcp_tool.name = info.name; + + if (!info.description.empty()) { + mcp_tool.description = mcp::make_optional(info.description); + } + + // Copy input schema if available + if (!info.inputSchema.isNull()) { + mcp_tool.inputSchema = mcp::make_optional(info.inputSchema); + } + + // Register tool with handler that routes through composite + // Capture tool name by value for the lambda + std::string tool_name = info.name; + + mcp_server_->registerTool( + mcp_tool, + [this, tool_name](const std::string& name, + const mcp::optional& arguments, + mcp::server::SessionContext& session) -> mcp::CallToolResult { + GOPHER_LOG_DEBUG("Tool handler invoked for: {}", tool_name); + try { + auto result = handleToolCall(tool_name, arguments, session); + GOPHER_LOG_DEBUG("Tool handler completed successfully for: {}", tool_name); + return result; + } catch (const std::exception& e) { + GOPHER_LOG_DEBUG("Exception in tool handler for {}: {}", tool_name, e.what()); + mcp::CallToolResult error_result; + error_result.isError = true; + error_result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent("Error: " + std::string(e.what())))); + return error_result; + } catch (...) { + GOPHER_LOG_DEBUG("Unknown exception in tool handler for: {}", tool_name); + mcp::CallToolResult error_result; + error_result.isError = true; + error_result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent("Error: Unknown exception"))); + return error_result; + } + }); + + ++count; + } + + tool_count_ = count; +} + +mcp::CallToolResult GatewayServer::handleToolCall( + const std::string& tool_name, + const mcp::optional& arguments, + mcp::server::SessionContext& session) { + + GOPHER_LOG_DEBUG("handleToolCall invoked for tool: {}", tool_name); + + mcp::CallToolResult result; + + // Get tool from composite + auto tool = composite_->tool(tool_name); + if (!tool) { + GOPHER_LOG_DEBUG("Tool not found in composite: {}", tool_name); + result.isError = true; + result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent("Tool not found: " + tool_name))); + return result; + } + GOPHER_LOG_DEBUG("Tool found in composite: {}", tool_name); + + // Convert MCP Metadata to JsonValue + JsonValue args; + if (arguments.has_value()) { + // Convert Metadata map to JsonValue object + for (const auto& pair : arguments.value()) { + const std::string& key = pair.first; + const mcp::MetadataValue& value = pair.second; + + // Handle different MetadataValue types + if (mcp::holds_alternative(value)) { + args[key] = mcp::get(value); + } else if (mcp::holds_alternative(value)) { + args[key] = static_cast(mcp::get(value)); + } else if (mcp::holds_alternative(value)) { + args[key] = mcp::get(value); + } else if (mcp::holds_alternative(value)) { + args[key] = mcp::get(value); + } + // Note: nested objects/arrays would need more complex handling + } + } + + // Use the owned dispatcher that was used to connect the backend servers + // This is critical - the backend MCP clients are bound to this dispatcher + if (!owned_dispatcher_) { + result.isError = true; + result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent("Internal error: no dispatcher"))); + return result; + } + + // Execute tool synchronously using mutex and condition variable + // The MCP server expects synchronous handlers, but our composite uses async + std::mutex mtx; + std::condition_variable cv; + bool completed = false; + Result tool_result = Result(Error(-1, "Timeout")); + + RunnableConfig runnable_config; + tool->invoke( + args, runnable_config, *owned_dispatcher_, + [&completed, &tool_result, &cv, &mtx](Result res) { + { + std::lock_guard lock(mtx); + tool_result = std::move(res); + completed = true; + } + cv.notify_all(); + }); + + // Wait for completion with timeout + // The background dispatcher thread will process the response + auto deadline = std::chrono::steady_clock::now() + config_.request_timeout; + + { + std::unique_lock lock(mtx); + while (!completed && !g_shutdown_requested) { + auto status = cv.wait_until(lock, deadline); + if (status == std::cv_status::timeout && !completed) { + result.isError = true; + result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent("Tool execution timeout"))); + return result; + } + } + } + + // Check if we're shutting down + if (g_shutdown_requested && !completed) { + result.isError = true; + result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent("Server shutting down"))); + return result; + } + + // Convert result to CallToolResult + if (core::isError(tool_result)) { + result.isError = true; + auto error = core::getError(tool_result); + result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent("Error: " + error.message))); + } else { + auto& value = mcp::get(tool_result); + + // Convert JsonValue to text representation + std::string text_result; + if (value.isString()) { + text_result = value.getString(); + } else { + text_result = value.toString(); + } + + result.content.push_back( + mcp::ExtendedContentBlock(mcp::TextContent(text_result))); + } + + return result; +} + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/server/mcp_gateway_main.cpp b/third_party/gopher-orch/src/gopher/orch/server/mcp_gateway_main.cpp new file mode 100644 index 00000000..ad05b390 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/server/mcp_gateway_main.cpp @@ -0,0 +1,323 @@ +/** + * @file mcp_gateway_main.cpp + * @brief Production MCP Gateway Server binary + * + * This is a production-ready gateway server that reads configuration from: + * 1. MCP_GATEWAY_CONFIG environment variable (JSON string) + * 2. MCP_GATEWAY_CONFIG_PATH file path (default: /etc/mcp/gateway-config.json) + * 3. MCP_GATEWAY_CONFIG_URL API endpoint (with optional MCP_GATEWAY_ACCESS_KEY) + * + * Environment Variables: + * MCP_GATEWAY_CONFIG - JSON config string (highest priority) + * MCP_GATEWAY_CONFIG_PATH - Path to config file (default: /etc/mcp/gateway-config.json) + * MCP_GATEWAY_CONFIG_URL - API URL to fetch config from + * MCP_GATEWAY_ACCESS_KEY - Access key for API authentication + * MCP_GATEWAY_PORT - Server port (default: 3003) + * MCP_GATEWAY_HOST - Server host (default: 0.0.0.0) + * MCP_GATEWAY_NAME - Server name (default: mcp-gateway) + * + * Configuration Formats: + * Manifest format (MCP_GATEWAY_CONFIG, MCP_GATEWAY_CONFIG_PATH): + * { + * "version": "2026-01-11", + * "metadata": { + * "accountId": "348716338765762562", + * "gatewayId": "694821867856330753", + * "gatewayName": "mcp-toolkit-01", + * "generatedAt": 1768114552523 + * }, + * "config": { + * "connectTimeout": 5000, + * "requestTimeout": 30000, + * "retryPolicy": { + * "maxAttempts": 5, + * "initialBackoff": 1000, + * "backoffMultiplier": 2.0, + * "maxBackoff": 30000, + * "jitter": 0.2, + * "retryableCodes": [429, 500, 502, 503, 504] + * } + * }, + * "servers": [ + * {"serverId": "...", "version": "2025-01-09", "name": "...", "url": "http://..."} + * ] + * } + * + * API response format (MCP_GATEWAY_CONFIG_URL): + * { + * "succeeded": true, + * "code": 200000000, + * "message": "success", + * "data": { } + * } + * + * Usage: + * # With environment variable config + * export MCP_GATEWAY_CONFIG='{"version":"2026-01-11","metadata":{"gatewayId":"123"},"config":{},"servers":[{"name":"server1","url":"http://localhost:3001/mcp"}]}' + * ./mcp_gateway + * + * # With config file + * export MCP_GATEWAY_CONFIG_PATH=/etc/mcp/my-config.json + * ./mcp_gateway + * + * # With API fetch + * export MCP_GATEWAY_CONFIG_URL=https://api.example.com/v1/mcp-gateway/123/manifest + * export MCP_GATEWAY_ACCESS_KEY=your-access-key + * ./mcp_gateway + */ + +#include +#include +#include +#include +#include + +#include + +#include "gopher/orch/server/gateway_server.h" + +using namespace gopher::orch::server; + +namespace { + +// Default configuration values +constexpr const char* DEFAULT_CONFIG_PATH = "/etc/mcp/gateway-config.json"; +constexpr int DEFAULT_PORT = 3003; +constexpr const char* DEFAULT_HOST = "0.0.0.0"; +constexpr const char* DEFAULT_NAME = "mcp-gateway"; + +// Environment variable names +constexpr const char* ENV_CONFIG = "MCP_GATEWAY_CONFIG"; +constexpr const char* ENV_CONFIG_PATH = "MCP_GATEWAY_CONFIG_PATH"; +constexpr const char* ENV_CONFIG_URL = "MCP_GATEWAY_CONFIG_URL"; +constexpr const char* ENV_ACCESS_KEY = "MCP_GATEWAY_ACCESS_KEY"; +constexpr const char* ENV_PORT = "MCP_GATEWAY_PORT"; +constexpr const char* ENV_HOST = "MCP_GATEWAY_HOST"; +constexpr const char* ENV_NAME = "MCP_GATEWAY_NAME"; + +/** + * Get environment variable with default value + */ +std::string getEnv(const char* name, const std::string& defaultValue = "") { + const char* value = std::getenv(name); + return value ? std::string(value) : defaultValue; +} + +/** + * Get environment variable as integer with default value + */ +int getEnvInt(const char* name, int defaultValue) { + const char* value = std::getenv(name); + if (value) { + try { + return std::stoi(value); + } catch (...) { + std::cerr << "Warning: Invalid integer value for " << name + << ", using default: " << defaultValue << std::endl; + } + } + return defaultValue; +} + +/** + * Check if a file exists + */ +bool fileExists(const std::string& path) { + std::ifstream file(path); + return file.good(); +} + +/** + * Read file contents + */ +std::string readFile(const std::string& path) { + std::ifstream file(path); + if (!file.is_open()) { + throw std::runtime_error("Failed to open file: " + path); + } + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} + +/** + * CURL write callback for API fetch + */ +size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, + std::string* output) { + size_t totalSize = size * nmemb; + output->append(static_cast(contents), totalSize); + return totalSize; +} + +/** + * Fetch config from API URL + */ +std::string fetchConfigFromUrl(const std::string& url, + const std::string& accessKey) { + CURL* curl = curl_easy_init(); + if (!curl) { + throw std::runtime_error("Failed to initialize CURL"); + } + + std::string response; + struct curl_slist* headers = nullptr; + + // Set URL + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + + // Set write callback + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + // Set headers + headers = curl_slist_append(headers, "Accept: application/json"); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + // Add access key if provided + if (!accessKey.empty()) { + std::string authHeader = "X-Access-Key: " + accessKey; + headers = curl_slist_append(headers, authHeader.c_str()); + } + + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + // Set timeout + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); + + // Follow redirects + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + + // Perform request + CURLcode res = curl_easy_perform(curl); + + // Check HTTP response code + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + + // Cleanup + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + throw std::runtime_error(std::string("CURL request failed: ") + + curl_easy_strerror(res)); + } + + if (httpCode != 200) { + throw std::runtime_error("API request failed with HTTP " + + std::to_string(httpCode)); + } + + return response; +} + +/** + * Load configuration from available sources + * Priority: ENV_CONFIG > ENV_CONFIG_PATH file > ENV_CONFIG_URL + */ +std::string loadConfig() { + // 1. Try environment variable (direct JSON) + std::string configEnv = getEnv(ENV_CONFIG); + if (!configEnv.empty()) { + return configEnv; + } + + // 2. Try config file path + std::string configPath = getEnv(ENV_CONFIG_PATH, DEFAULT_CONFIG_PATH); + if (fileExists(configPath)) { + return readFile(configPath); + } + + // 3. Try API URL + std::string configUrl = getEnv(ENV_CONFIG_URL); + if (!configUrl.empty()) { + std::string accessKey = getEnv(ENV_ACCESS_KEY); + return fetchConfigFromUrl(configUrl, accessKey); + } + + // No config found + throw std::runtime_error( + "No configuration found. Provide one of:\n" + " - " + + std::string(ENV_CONFIG) + + " environment variable (JSON string)\n" + " - " + + std::string(ENV_CONFIG_PATH) + + " file path (default: " + DEFAULT_CONFIG_PATH + + ")\n" + " - " + + std::string(ENV_CONFIG_URL) + " API endpoint URL"); +} + +/** + * Print startup banner + */ +void printBanner(const GatewayServerConfig& config) { + std::cout << std::endl; + std::cout << "╔═══════════════════════════════════════════════════════════╗" + << std::endl; + std::cout << "║ MCP Gateway Server (Production) ║" + << std::endl; + std::cout << "╠═══════════════════════════════════════════════════════════╣" + << std::endl; + std::cout << "║ Name: " << config.name; + for (size_t i = config.name.length(); i < 51; ++i) std::cout << " "; + std::cout << "║" << std::endl; + std::cout << "║ Host: " << config.host; + for (size_t i = config.host.length(); i < 51; ++i) std::cout << " "; + std::cout << "║" << std::endl; + std::cout << "║ Port: " << config.port; + std::string portStr = std::to_string(config.port); + for (size_t i = portStr.length(); i < 51; ++i) std::cout << " "; + std::cout << "║" << std::endl; + std::cout << "╚═══════════════════════════════════════════════════════════╝" + << std::endl; + std::cout << std::endl; +} + +} // namespace + +int main(int argc, char* argv[]) { + // Initialize CURL globally + curl_global_init(CURL_GLOBAL_ALL); + + try { + // Load configuration + std::string serverJson = loadConfig(); + + // Build gateway config from environment + GatewayServerConfig config; + config.name = getEnv(ENV_NAME, DEFAULT_NAME); + config.host = getEnv(ENV_HOST, DEFAULT_HOST); + config.port = getEnvInt(ENV_PORT, DEFAULT_PORT); + + // Print startup banner + printBanner(config); + + // Create gateway from JSON configuration + auto gateway = GatewayServer::create(serverJson, config); + + // Check if creation succeeded + if (!gateway->getError().empty()) { + std::cerr << "Error: " << gateway->getError() << std::endl; + curl_global_cleanup(); + return 1; + } + + std::cout << "Gateway ready: " << gateway->serverCount() << " servers, " + << gateway->toolCount() << " tools" << std::endl; + + // Start listening (blocks until Ctrl+C or SIGTERM) + int result = gateway->listen(config.port); + + curl_global_cleanup(); + return result; + + } catch (const std::exception& e) { + std::cerr << "Fatal error: " << e.what() << std::endl; + curl_global_cleanup(); + return 1; + } +} diff --git a/third_party/gopher-orch/src/gopher/orch/server/mcp_server.cc b/third_party/gopher-orch/src/gopher/orch/server/mcp_server.cc new file mode 100644 index 00000000..5c0b7938 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/server/mcp_server.cc @@ -0,0 +1,602 @@ +// MCPServer implementation +// +// Wraps the gopher-mcp client to implement the protocol-agnostic Server +// interface. All callbacks are invoked in dispatcher thread context. + +#include "gopher/orch/server/mcp_server.h" + +#include +#include + +#include "mcp/json/json_serialization.h" +#include "mcp/logging/log_macros.h" + +// Define log component for this file +#undef GOPHER_LOG_COMPONENT +#define GOPHER_LOG_COMPONENT "MCPServer" + +namespace gopher { +namespace orch { +namespace server { + +// Import orch core utilities +using namespace gopher::orch::core; + +namespace { + +// Atomic counter for generating unique IDs +std::atomic g_id_counter{0}; + +// Helper to convert variant content to JsonValue for C++14 +// Instead of std::visit (C++17), we use type checking and dispatching +template +JsonValue contentToJsonSingle(const T& content); + +template <> +JsonValue contentToJsonSingle(const mcp::TextContent& text) { + JsonValue result = JsonValue::object(); + result["type"] = "text"; + result["text"] = text.text; + return result; +} + +template <> +JsonValue contentToJsonSingle(const mcp::ImageContent& image) { + JsonValue result = JsonValue::object(); + result["type"] = "image"; + result["data"] = image.data; + result["mimeType"] = image.mimeType; + return result; +} + +template <> +JsonValue contentToJsonSingle(const mcp::AudioContent& audio) { + JsonValue result = JsonValue::object(); + result["type"] = "audio"; + result["data"] = audio.data; + result["mimeType"] = audio.mimeType; + return result; +} + +template <> +JsonValue contentToJsonSingle(const mcp::ResourceLink& link) { + JsonValue result = JsonValue::object(); + result["type"] = "resource_link"; + // ResourceLink inherits from Resource, so uri and name are direct members + result["uri"] = link.uri; + if (!link.name.empty()) { + result["name"] = link.name; + } + return result; +} + +template <> +JsonValue contentToJsonSingle(const mcp::EmbeddedResource& embedded) { + JsonValue result = JsonValue::object(); + result["type"] = "embedded_resource"; + // EmbeddedResource has a nested resource member + result["uri"] = embedded.resource.uri; + if (!embedded.resource.name.empty()) { + result["name"] = embedded.resource.name; + } + return result; +} + +// Convert a single ExtendedContentBlock to JsonValue +// Uses mcp::holds_alternative and mcp::get for C++14 variant access +JsonValue extendedContentBlockToJson(const mcp::ExtendedContentBlock& block) { + if (mcp::holds_alternative(block)) { + return contentToJsonSingle(mcp::get(block)); + } else if (mcp::holds_alternative(block)) { + return contentToJsonSingle(mcp::get(block)); + } else if (mcp::holds_alternative(block)) { + return contentToJsonSingle(mcp::get(block)); + } else if (mcp::holds_alternative(block)) { + return contentToJsonSingle(mcp::get(block)); + } else if (mcp::holds_alternative(block)) { + return contentToJsonSingle(mcp::get(block)); + } + return JsonValue::null(); +} + +} // namespace + +// Generate unique ID +std::string MCPServer::generateId() { + std::ostringstream oss; + oss << "mcp-server-" << ++g_id_counter; + return oss.str(); +} + +// Constructor +MCPServer::MCPServer(const MCPServerConfig& config) + : id_(generateId()), config_(config) {} + +// Destructor +MCPServer::~MCPServer() { + // Client cleanup is handled by unique_ptr +} + +// Factory method +void MCPServer::create(const MCPServerConfig& config, + Dispatcher& dispatcher, + std::function)> callback, + bool auto_connect) { + // Create the server instance + // We need to use a raw ptr temporarily then wrap in shared_ptr + MCPServer* raw_server = new MCPServer(config); + auto server = std::shared_ptr(raw_server); + + if (auto_connect) { + // Start connection process + server->initialize(dispatcher, std::move(callback)); + } else { + // Return immediately, user must call connect() + MCPServerPtr server_copy = server; + dispatcher.post( + [callback, server_copy]() { callback(makeSuccess(server_copy)); }); + } +} + +// Initialize connection +void MCPServer::initialize(Dispatcher& dispatcher, + std::function)> callback) { + state_ = ConnectionState::CONNECTING; + + // Create MCP client configuration + mcp::client::McpClientConfig client_config; + client_config.client_name = config_.client_name; + client_config.client_version = config_.client_version; + client_config.request_timeout = config_.request_timeout; + client_config.protocol_initialization_timeout = config_.connect_timeout; + client_config.max_retries = config_.max_connect_retries; + client_config.initial_retry_delay = config_.retry_delay; + + // Set transport type + switch (config_.transport_type) { + case MCPServerConfig::TransportType::STDIO: + client_config.preferred_transport = mcp::TransportType::Stdio; + break; + case MCPServerConfig::TransportType::HTTP_SSE: + client_config.preferred_transport = mcp::TransportType::HttpSse; + break; + case MCPServerConfig::TransportType::WEBSOCKET: + client_config.preferred_transport = mcp::TransportType::WebSocket; + break; + } + + // Create the MCP client + client_ = std::make_unique(client_config); + + // Build connection URI based on transport type + std::string uri; + switch (config_.transport_type) { + case MCPServerConfig::TransportType::STDIO: { + // For stdio, we need to construct the command URI + // Format: stdio://?arg1&arg2... + std::ostringstream oss; + oss << "stdio://" << config_.stdio_transport.command; + if (!config_.stdio_transport.args.empty()) { + oss << "?"; + for (size_t i = 0; i < config_.stdio_transport.args.size(); ++i) { + if (i > 0) + oss << "&"; + oss << config_.stdio_transport.args[i]; + } + } + uri = oss.str(); + break; + } + case MCPServerConfig::TransportType::HTTP_SSE: + uri = config_.http_sse_transport.url; + break; + case MCPServerConfig::TransportType::WEBSOCKET: + uri = config_.websocket_transport.url; + break; + } + + // Connect to the server + mcp::VoidResult connect_result = client_->connect(uri); + if (mcp::holds_alternative(connect_result)) { + state_ = ConnectionState::FAILED; + const mcp::Error& err = mcp::get(connect_result); + callback(makeOrchError( + OrchError::CONNECTION_FAILED, + "Failed to connect to MCP server: " + err.message)); + return; + } + + // Wait for connection to be fully established before protocol initialization + // The connect() call above only initiates async connection - we need to wait + // for the connection event to be processed and connected_ flag to be set + MCPServer* this_ptr = this; + auto self = std::shared_ptr(std::static_pointer_cast( + this_ptr->Server::shared_from_this())); + + // Poll for connection readiness with timeout + // Use the connect_timeout from config, or default to 15 seconds (SSL handshake can take several seconds) + auto start_time = std::make_shared(std::chrono::steady_clock::now()); + auto timeout = config_.connect_timeout.count() > 0 ? config_.connect_timeout : std::chrono::seconds(15); + + // CRITICAL FIX: Capture dispatcher by pointer instead of reference to avoid + // dangling reference when lambdas are invoked asynchronously after this + // function returns. + Dispatcher* dispatcher_ptr = &dispatcher; + + auto waitForConnection = std::make_shared>(); + *waitForConnection = [self, callback, dispatcher_ptr, start_time, timeout, waitForConnection]() { + if (self->client_->isConnected()) { + // Connection is ready - now initialize protocol + auto init_future_ptr = std::make_shared>( + self->client_->initializeProtocol()); + + // CRITICAL FIX: Use non-blocking polling instead of blocking get(). + // Blocking get() inside a posted callback prevents the event loop from + // running, which means timeouts can't trigger and the loop appears hung. + auto waitForInit = std::make_shared>(); + *waitForInit = [self, callback, init_future_ptr, dispatcher_ptr, start_time, timeout, waitForInit]() { + // Check timeout first + auto elapsed = std::chrono::steady_clock::now() - *start_time; + if (elapsed > timeout) { + self->state_ = ConnectionState::FAILED; + auto timeout_secs = std::chrono::duration_cast(timeout).count(); + callback(makeOrchError( + OrchError::CONNECTION_FAILED, + "Init timeout - protocol initialization took longer than " + + std::to_string(timeout_secs) + " seconds")); + return; + } + + // Non-blocking check if future is ready + if (init_future_ptr->wait_for(std::chrono::milliseconds(0)) == std::future_status::ready) { + try { + mcp::InitializeResult init_result = init_future_ptr->get(); + self->onInitialized(*dispatcher_ptr, init_result, callback, start_time, timeout); + } catch (const std::exception& e) { + self->state_ = ConnectionState::FAILED; + callback(makeOrchError( + OrchError::CONNECTION_FAILED, + std::string("Failed to initialize MCP protocol: ") + e.what())); + } + } else { + // Not ready yet - schedule another check in 10ms using timer + // CRITICAL: Create a shared timer holder that captures itself in callback + // to keep the timer alive until it fires + struct TimerHolder { + mcp::event::TimerPtr timer; + }; + auto holder = std::make_shared(); + holder->timer = dispatcher_ptr->createTimer([holder, waitForInit]() { + (void)holder; // Keep holder alive until callback fires + (*waitForInit)(); + }); + holder->timer->enableTimer(std::chrono::milliseconds(10)); + } + }; + (*waitForInit)(); + } else { + // Check timeout + auto elapsed = std::chrono::steady_clock::now() - *start_time; + if (elapsed > timeout) { + self->state_ = ConnectionState::FAILED; + auto timeout_secs = std::chrono::duration_cast(timeout).count(); + callback(makeOrchError( + OrchError::CONNECTION_FAILED, + "Connection timeout - failed to establish connection within " + + std::to_string(timeout_secs) + " seconds")); + return; + } + + // Not connected yet - retry in 10ms using timer (non-blocking) + // CRITICAL: Create a shared timer holder that captures itself in callback + struct TimerHolder { + mcp::event::TimerPtr timer; + }; + auto holder = std::make_shared(); + holder->timer = dispatcher_ptr->createTimer([holder, waitForConnection]() { + (void)holder; // Keep holder alive until callback fires + (*waitForConnection)(); + }); + holder->timer->enableTimer(std::chrono::milliseconds(10)); + } + }; + + // Start the connection polling + dispatcher_ptr->post(*waitForConnection); +} + +// Handle protocol initialization complete +void MCPServer::onInitialized( + Dispatcher& dispatcher, + const mcp::InitializeResult& init_result, + std::function)> callback, + std::shared_ptr start_time, + std::chrono::milliseconds timeout) { + // Store server info and capabilities + if (init_result.serverInfo) { + server_info_ = *init_result.serverInfo; + } + capabilities_ = init_result.capabilities; + + // List available tools + auto self = std::static_pointer_cast(Server::shared_from_this()); + auto tools_future_ptr = + std::make_shared>(client_->listTools()); + + // CRITICAL FIX: Use non-blocking polling instead of blocking get(). + Dispatcher* dispatcher_ptr = &dispatcher; + auto waitForTools = std::make_shared>(); + *waitForTools = [self, callback, tools_future_ptr, dispatcher_ptr, start_time, timeout, waitForTools]() { + // Check timeout first + auto elapsed = std::chrono::steady_clock::now() - *start_time; + if (elapsed > timeout) { + // Tools listing timed out, but connection is still valid + self->state_ = ConnectionState::CONNECTED; + callback(makeSuccess(self)); + return; + } + + // Non-blocking check if future is ready + if (tools_future_ptr->wait_for(std::chrono::milliseconds(0)) == std::future_status::ready) { + try { + mcp::ListToolsResult tools_result = tools_future_ptr->get(); + self->onToolsListed(tools_result); + self->state_ = ConnectionState::CONNECTED; + + // Execute any pending callbacks + for (auto& pending : self->pending_on_connect_) { + pending(); + } + self->pending_on_connect_.clear(); + + callback(makeSuccess(self)); + } catch (const std::exception& e) { + // Tools listing failed, but connection is still valid + self->state_ = ConnectionState::CONNECTED; + callback(makeSuccess(self)); + } + } else { + // Not ready yet - schedule another check in 10ms using timer + // CRITICAL: Create a shared timer holder that captures itself in callback + struct TimerHolder { + mcp::event::TimerPtr timer; + }; + auto holder = std::make_shared(); + holder->timer = dispatcher_ptr->createTimer([holder, waitForTools]() { + (void)holder; // Keep holder alive until callback fires + (*waitForTools)(); + }); + holder->timer->enableTimer(std::chrono::milliseconds(10)); + } + }; + (*waitForTools)(); +} + +// Handle tools listed +void MCPServer::onToolsListed(const mcp::ListToolsResult& tools_result) { + tools_.clear(); + tools_.reserve(tools_result.tools.size()); + + for (const auto& mcp_tool : tools_result.tools) { + tools_.push_back(toServerToolInfo(mcp_tool)); + } +} + +// Convert MCP Tool to ServerToolInfo +ServerToolInfo MCPServer::toServerToolInfo(const mcp::Tool& tool) { + ServerToolInfo info; + info.name = tool.name; + if (tool.description) { + info.description = *tool.description; + } + if (tool.inputSchema) { + // Convert ToolInputSchema to JsonValue + // The inputSchema is already JSON compatible + // info.inputSchema = JsonValue::object(); + info.inputSchema = *(tool.inputSchema); + // TODO: Proper conversion of input schema when needed + } + return info; +} + +// Convert MCP content to JsonValue +JsonValue MCPServer::contentToJson( + const std::vector& content) { + if (content.empty()) { + return JsonValue::null(); + } + + if (content.size() == 1) { + return extendedContentBlockToJson(content[0]); + } + + // Multiple content blocks - return as array + JsonValue result = JsonValue::array(); + for (const auto& block : content) { + result.push_back(extendedContentBlockToJson(block)); + } + return result; +} + +// Connect to the server +void MCPServer::connect(Dispatcher& dispatcher, ConnectionCallback callback) { + if (state_ == ConnectionState::CONNECTED) { + dispatcher.post( + [callback]() { callback(makeSuccess(nullptr)); }); + return; + } + + if (state_ == ConnectionState::CONNECTING) { + // Already connecting, queue the callback + pending_on_connect_.push_back( + [callback]() { callback(makeSuccess(nullptr)); }); + return; + } + + // Need to initialize + auto self = std::static_pointer_cast(Server::shared_from_this()); + initialize(dispatcher, [callback](Result result) { + if (mcp::holds_alternative(result)) { + callback(makeSuccess(nullptr)); + } else { + callback(Result(mcp::get(result))); + } + }); +} + +// Disconnect from the server +void MCPServer::disconnect(Dispatcher& dispatcher, + std::function callback) { + if (state_ == ConnectionState::DISCONNECTED) { + if (callback) { + dispatcher.post(callback); + } + return; + } + + state_ = ConnectionState::DISCONNECTED; + + if (client_) { + client_->disconnect(); + } + + if (callback) { + dispatcher.post(callback); + } +} + +// List available tools +void MCPServer::listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) { + if (!this->Server::isConnected()) { + dispatcher.post([callback]() { + callback(makeOrchError>( + OrchError::NOT_CONNECTED, "Server is not connected")); + }); + return; + } + + // Return cached tools if available + if (!tools_.empty()) { + auto tools_copy = tools_; + dispatcher.post( + [callback, tools_copy]() { callback(makeSuccess(tools_copy)); }); + return; + } + + // Fetch tools from server + auto self = std::static_pointer_cast(Server::shared_from_this()); + auto tools_future_ptr = + std::make_shared>(client_->listTools()); + + dispatcher.post([self, callback, tools_future_ptr]() { + try { + mcp::ListToolsResult tools_result = tools_future_ptr->get(); + self->onToolsListed(tools_result); + callback(makeSuccess(self->tools_)); + } catch (const std::exception& e) { + callback(makeOrchError>( + OrchError::INTERNAL_ERROR, e.what())); + } + }); +} + +// Get a tool by name as a Runnable +JsonRunnablePtr MCPServer::tool(const std::string& name) { + // Check cache first + auto it = tool_cache_.find(name); + if (it != tool_cache_.end()) { + return it->second; + } + + // Find tool info + ServerToolInfo info; + bool found = false; + for (const auto& t : tools_) { + if (t.name == name) { + info = t; + found = true; + break; + } + } + + if (!found) { + // Create a placeholder tool info + info.name = name; + } + + // Create ServerTool wrapper + auto tool_ptr = + std::make_shared(Server::shared_from_this(), info); + tool_cache_[name] = tool_ptr; + return tool_ptr; +} + +// Call a tool directly +void MCPServer::callTool(const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) { + (void)config; // Config is handled internally by MCP client + + if (!this->Server::isConnected()) { + dispatcher.post([callback]() { + callback(makeOrchError(OrchError::NOT_CONNECTED, + "Server is not connected")); + }); + return; + } + + // Convert JsonValue to mcp::optional + mcp::optional mcp_args; + if (!arguments.isNull() && arguments.isObject()) { + // Use the existing jsonToMetadata function from json_serialization.h + mcp_args = mcp::json::jsonToMetadata(arguments); + } + + auto self = std::static_pointer_cast(Server::shared_from_this()); + + GOPHER_LOG_DEBUG("Initiating tool call: {}", name); + + auto tool_future_ptr = std::make_shared>( + client_->callTool(name, mcp_args)); + + GOPHER_LOG_DEBUG("Tool call future created, spawning wait thread for: {}", name); + + // Use a separate thread to wait on the future, then post result to dispatcher + // This avoids blocking the dispatcher thread which could cause deadlocks + std::thread([self, callback, tool_future_ptr, name, &dispatcher]() { + GOPHER_LOG_DEBUG("Wait thread started, awaiting MCP response for tool: {}", name); + try { + mcp::CallToolResult result = tool_future_ptr->get(); + GOPHER_LOG_DEBUG("MCP tool call completed for: {}", name); + + // Post the result back to the dispatcher + GOPHER_LOG_DEBUG("Posting result callback to dispatcher for tool: {}", name); + dispatcher.post([self, callback, result, name]() { + GOPHER_LOG_DEBUG("Processing tool result in dispatcher context for: {}", name); + if (result.isError) { + JsonValue error_content = contentToJson(result.content); + callback(makeOrchError(OrchError::INTERNAL_ERROR, + error_content.toString())); + } else { + JsonValue json_result = contentToJson(result.content); + callback(makeSuccess(json_result)); + } + GOPHER_LOG_DEBUG("Tool result callback completed for: {}", name); + }); + } catch (const std::exception& e) { + GOPHER_LOG_DEBUG("Exception in wait thread for tool {}: {}", name, e.what()); + dispatcher.post([callback, e]() { + callback(makeOrchError(OrchError::INTERNAL_ERROR, e.what())); + }); + } + }).detach(); + + GOPHER_LOG_DEBUG("Tool call initiated successfully, wait thread detached for: {}", name); +} + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/src/gopher/orch/server/rest_server.cc b/third_party/gopher-orch/src/gopher/orch/server/rest_server.cc new file mode 100644 index 00000000..460f8914 --- /dev/null +++ b/third_party/gopher-orch/src/gopher/orch/server/rest_server.cc @@ -0,0 +1,408 @@ +// RESTServer implementation +// +// Provides REST API access through the Server interface. +// The DefaultHttpClient provides a basic HTTP implementation. +// For production use, inject a custom HttpClient with a robust HTTP library. + +#include "gopher/orch/server/rest_server.h" + +#include +#include +#include +#include +#include + +namespace gopher { +namespace orch { +namespace server { + +namespace { + +// Atomic counter for generating unique IDs +std::atomic g_rest_id_counter{0}; + +// Parse URL into components +struct UrlComponents { + std::string scheme; // http or https + std::string host; + uint16_t port = 0; + std::string path; + std::string query; + + bool parse(const std::string& url) { + // Simple URL parser + // Format: scheme://host:port/path?query + + size_t scheme_end = url.find("://"); + if (scheme_end == std::string::npos) { + return false; + } + scheme = url.substr(0, scheme_end); + + size_t host_start = scheme_end + 3; + size_t path_start = url.find('/', host_start); + size_t query_start = url.find('?', host_start); + + std::string host_port; + if (path_start != std::string::npos) { + host_port = url.substr(host_start, path_start - host_start); + if (query_start != std::string::npos && query_start > path_start) { + path = url.substr(path_start, query_start - path_start); + query = url.substr(query_start + 1); + } else { + path = url.substr(path_start); + } + } else if (query_start != std::string::npos) { + host_port = url.substr(host_start, query_start - host_start); + query = url.substr(query_start + 1); + path = "/"; + } else { + host_port = url.substr(host_start); + path = "/"; + } + + // Parse host:port + size_t port_sep = host_port.find(':'); + if (port_sep != std::string::npos) { + host = host_port.substr(0, port_sep); + port = static_cast(std::stoi(host_port.substr(port_sep + 1))); + } else { + host = host_port; + port = (scheme == "https") ? 443 : 80; + } + + return true; + } +}; + +// Base64 encoding for basic auth +std::string base64Encode(const std::string& input) { + static const char* chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + std::string result; + result.reserve(((input.size() + 2) / 3) * 4); + + for (size_t i = 0; i < input.size(); i += 3) { + uint32_t n = static_cast(input[i]) << 16; + if (i + 1 < input.size()) + n |= static_cast(input[i + 1]) << 8; + if (i + 2 < input.size()) + n |= static_cast(input[i + 2]); + + result += chars[(n >> 18) & 0x3F]; + result += chars[(n >> 12) & 0x3F]; + result += (i + 1 < input.size()) ? chars[(n >> 6) & 0x3F] : '='; + result += (i + 2 < input.size()) ? chars[n & 0x3F] : '='; + } + + return result; +} + +// URL encode a string +std::string urlEncode(const std::string& value) { + std::ostringstream escaped; + escaped.fill('0'); + escaped << std::hex; + + for (char c : value) { + if (isalnum(static_cast(c)) || c == '-' || c == '_' || + c == '.' || c == '~') { + escaped << c; + } else { + escaped << '%' << std::setw(2) + << static_cast(static_cast(c)); + } + } + + return escaped.str(); +} + +} // namespace + +// ============================================================================= +// DefaultHttpClient Implementation +// ============================================================================= + +// DefaultHttpClient now uses the CurlHttpClient implementation +// This ensures all HTTP clients in the system use the same robust implementation +class DefaultHttpClient::Impl { + public: + Impl() { + // Create a CurlHttpClient instance to delegate to + http_client_ = createCurlHttpClient(); + } + + void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + HttpClient::ResponseCallback callback) { + // Delegate to CurlHttpClient + http_client_->request(method, url, headers, body, dispatcher, std::move(callback)); + } + + private: + std::shared_ptr http_client_; +}; + +DefaultHttpClient::DefaultHttpClient() : impl_(std::make_unique()) {} + +DefaultHttpClient::~DefaultHttpClient() = default; + +void DefaultHttpClient::request( + HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + ResponseCallback callback) { + impl_->request(method, url, headers, body, dispatcher, std::move(callback)); +} + +// ============================================================================= +// RESTServer Implementation +// ============================================================================= + +std::string RESTServer::generateId() { + std::ostringstream oss; + oss << "rest-server-" << ++g_rest_id_counter; + return oss.str(); +} + +RESTServer::RESTServer(const RESTServerConfig& config, + HttpClientPtr http_client) + : id_(generateId()), + config_(config), + http_client_(std::move(http_client)) {} + +RESTServer::~RESTServer() = default; + +RESTServer::Ptr RESTServer::create(const RESTServerConfig& config) { + auto http_client = std::make_shared(); + return create(config, http_client); +} + +RESTServer::Ptr RESTServer::create(const RESTServerConfig& config, + HttpClientPtr http_client) { + return std::shared_ptr( + new RESTServer(config, std::move(http_client))); +} + +void RESTServer::connect(Dispatcher& dispatcher, ConnectionCallback callback) { + // REST servers are stateless - no connection needed + // Just verify the configuration is valid + if (config_.base_url.empty()) { + dispatcher.post([callback]() { + callback(Result( + Error(OrchError::INVALID_ARGUMENT, "base_url is required"))); + }); + return; + } + + state_ = ConnectionState::CONNECTED; + dispatcher.post( + [callback]() { callback(core::makeSuccess(nullptr)); }); +} + +void RESTServer::disconnect(Dispatcher& dispatcher, + std::function callback) { + state_ = ConnectionState::DISCONNECTED; + if (callback) { + dispatcher.post(std::move(callback)); + } +} + +void RESTServer::listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) { + std::vector tools; + tools.reserve(config_.tools.size()); + + for (const auto& entry : config_.tools) { + tools.push_back(entry.second.info); + } + + dispatcher.post([tools = std::move(tools), callback]() { + callback(core::makeSuccess(std::move(tools))); + }); +} + +JsonRunnablePtr RESTServer::tool(const std::string& name) { + std::lock_guard lock(mutex_); + + // Check cache + auto it = tool_cache_.find(name); + if (it != tool_cache_.end()) { + return it->second; + } + + // Find tool config + auto tool_it = config_.tools.find(name); + if (tool_it == config_.tools.end()) { + return nullptr; + } + + // Create ServerTool wrapper + auto tool_ptr = + std::make_shared(shared_from_this(), tool_it->second.info); + tool_cache_[name] = tool_ptr; + return tool_ptr; +} + +void RESTServer::callTool(const std::string& name, + const JsonValue& arguments, + const RunnableConfig& config, + Dispatcher& dispatcher, + JsonCallback callback) { + (void)config; // RunnableConfig not used for REST calls + + // Find tool endpoint + auto tool_it = config_.tools.find(name); + if (tool_it == config_.tools.end()) { + dispatcher.post([callback, name]() { + callback(Result( + Error(OrchError::TOOL_NOT_FOUND, "Tool not found: " + name))); + }); + return; + } + + const auto& endpoint = tool_it->second; + + // Build URL with path parameters + std::string url = buildUrl(endpoint.path, arguments); + + // Build headers + auto headers = buildHeaders(); + + // Add Content-Type for body + std::string body; + if (endpoint.send_body && !arguments.isNull()) { + headers["Content-Type"] = "application/json"; + body = arguments.toString(); + } + + // Make HTTP request + http_client_->request( + endpoint.method, url, headers, body, dispatcher, + [callback, endpoint](Result result) { + if (mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); + return; + } + + const auto& response = mcp::get(result); + + // Check for HTTP errors + if (!response.isSuccess()) { + std::ostringstream error_msg; + error_msg << "HTTP " << response.status_code; + if (!response.body.empty()) { + error_msg << ": " << response.body.substr(0, 200); + } + callback(Result( + Error(OrchError::INTERNAL_ERROR, error_msg.str()))); + return; + } + + // Parse response body as JSON + if (response.body.empty()) { + callback(core::makeSuccess(JsonValue::object())); + return; + } + + try { + JsonValue json_result = JsonValue::parse(response.body); + callback(core::makeSuccess(std::move(json_result))); + } catch (const std::exception& e) { + // Return raw body as string if not JSON + callback(core::makeSuccess(JsonValue(response.body))); + } + }); +} + +std::string RESTServer::buildUrl(const std::string& path, + const JsonValue& args) const { + std::string url = config_.base_url; + + // Replace path parameters + std::string result_path = path; + std::regex param_regex("\\{([^}]+)\\}"); + std::smatch match; + std::string::const_iterator search_start = result_path.cbegin(); + + std::string final_path; + size_t last_pos = 0; + + while ( + std::regex_search(search_start, result_path.cend(), match, param_regex)) { + std::string param_name = match[1].str(); + std::string replacement; + + // Get value from arguments + if (args.contains(param_name)) { + const JsonValue& value = args[param_name]; + if (value.isString()) { + replacement = urlEncode(value.getString()); + } else if (value.isInteger()) { + replacement = std::to_string(value.getInt()); + } else if (value.isFloat()) { + replacement = std::to_string(value.getFloat()); + } else if (value.isBoolean()) { + replacement = value.getBool() ? "true" : "false"; + } + } + + size_t match_start = static_cast(match.position()) + + (search_start - result_path.cbegin()); + final_path += result_path.substr(last_pos, match_start - last_pos); + final_path += replacement; + last_pos = match_start + match.length(); + + search_start = match.suffix().first; + } + + final_path += result_path.substr(last_pos); + + return url + final_path; +} + +std::map RESTServer::buildHeaders() const { + std::map headers = config_.default_headers; + + // Add authentication + switch (config_.auth.type) { + case RESTServerConfig::AuthConfig::Type::BEARER: + headers["Authorization"] = "Bearer " + config_.auth.bearer_token; + break; + case RESTServerConfig::AuthConfig::Type::BASIC: { + std::string credentials = + config_.auth.username + ":" + config_.auth.password; + headers["Authorization"] = "Basic " + base64Encode(credentials); + break; + } + case RESTServerConfig::AuthConfig::Type::API_KEY: + headers[config_.auth.api_key_header] = config_.auth.api_key; + break; + case RESTServerConfig::AuthConfig::Type::NONE: + default: + break; + } + + return headers; +} + +void RESTServer::setAuth(const RESTServerConfig::AuthConfig& auth) { + std::lock_guard lock(mutex_); + config_.auth = auth; +} + +void RESTServer::setDefaultHeader(const std::string& name, + const std::string& value) { + std::lock_guard lock(mutex_); + config_.default_headers[name] = value; +} + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/src/orch/hello.cpp b/third_party/gopher-orch/src/orch/hello.cc similarity index 100% rename from src/orch/hello.cpp rename to third_party/gopher-orch/src/orch/hello.cc diff --git a/tests/CMakeLists.txt b/third_party/gopher-orch/tests/CMakeLists.txt similarity index 51% rename from tests/CMakeLists.txt rename to third_party/gopher-orch/tests/CMakeLists.txt index 88a9c7d4..a9de9053 100644 --- a/tests/CMakeLists.txt +++ b/third_party/gopher-orch/tests/CMakeLists.txt @@ -10,6 +10,52 @@ set(ORCH_CORE_TEST_SOURCES orch/hello_test.cpp ) +# New gopher/orch framework tests - split by component +set(ORCH_FRAMEWORK_TEST_SOURCES + gopher/orch/lambda_test.cc + gopher/orch/sequence_test.cc + gopher/orch/parallel_test.cc + gopher/orch/router_test.cc + gopher/orch/retry_test.cc + gopher/orch/timeout_test.cc + gopher/orch/fallback_test.cc + gopher/orch/circuit_breaker_test.cc + gopher/orch/state_graph_test.cc + gopher/orch/state_machine_test.cc + gopher/orch/callback_manager_test.cc + gopher/orch/human_approval_test.cc + gopher/orch/mock_server_test.cc + gopher/orch/server_composite_test.cc + gopher/orch/mcp_server_test.cc + gopher/orch/rest_server_test.cc + gopher/orch/integration_test.cc + gopher/orch/tools_fetcher_integration_test.cpp +) + +# LLM, Agent, and ToolRegistry tests +set(ORCH_AGENT_TEST_SOURCES + gopher/orch/llm_provider_test.cc + gopher/orch/llm_runnable_test.cc + gopher/orch/agent_test.cc + gopher/orch/agent_state_test.cc + gopher/orch/agent_runnable_test.cc + gopher/orch/tool_registry_test.cc + gopher/orch/tool_runnable_test.cc + gopher/orch/tools_fetcher_test.cpp +) + +# FFI tests - organized by component +set(FFI_TEST_SOURCES + gopher/orch/FFI/ffi_types_test.cc + gopher/orch/FFI/ffi_error_test.cc + gopher/orch/FFI/ffi_handle_test.cc + gopher/orch/FFI/ffi_json_test.cc + gopher/orch/FFI/ffi_core_test.cc + gopher/orch/FFI/ffi_builder_test.cc + gopher/orch/FFI/ffi_raii_test.cc + gopher/orch/FFI/ffi_lambda_test.cc +) + # Helper function to create orch test executables function(add_orch_test test_name test_sources) add_executable(${test_name} ${test_sources} ${TEST_UTIL_SOURCES}) @@ -30,6 +76,8 @@ function(add_orch_test test_name test_sources) target_include_directories(${test_name} PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/tests + ${CMAKE_SOURCE_DIR}/tests/gopher/orch + ${CMAKE_SOURCE_DIR}/tests/gopher/orch/FFI ${GOPHER_MCP_INCLUDE_DIR} ) @@ -43,9 +91,21 @@ endfunction() # Create individual orch test executables add_orch_test(hello_test "${ORCH_CORE_TEST_SOURCES}" "orch") +# Create orch framework test executable +add_orch_test(orch_framework_test "${ORCH_FRAMEWORK_TEST_SOURCES}" "orch-framework") + +# Create agent test executable (LLM, Agent, ToolRegistry) +add_orch_test(agent_test "${ORCH_AGENT_TEST_SOURCES}" "agent") + +# Create FFI test executable +add_orch_test(ffi_test "${FFI_TEST_SOURCES}" "ffi") + # Create a combined orch test executable for convenience add_executable(gopher-orch-tests ${ORCH_CORE_TEST_SOURCES} + ${ORCH_FRAMEWORK_TEST_SOURCES} + ${ORCH_AGENT_TEST_SOURCES} + ${FFI_TEST_SOURCES} ${TEST_UTIL_SOURCES} ) @@ -68,6 +128,8 @@ target_link_libraries(gopher-orch-tests target_include_directories(gopher-orch-tests PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/tests + ${CMAKE_SOURCE_DIR}/tests/gopher/orch + ${CMAKE_SOURCE_DIR}/tests/gopher/orch/FFI ${GOPHER_MCP_INCLUDE_DIR} ) diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_builder_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_builder_test.cc new file mode 100644 index 00000000..2ce134af --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_builder_test.cc @@ -0,0 +1,111 @@ +/** + * @file ffi_builder_test.cc + * @brief Unit tests for FFI builder components + * + * Tests: + * - SequenceImpl (Creation) + * - ParallelImpl (Creation) + * - RouterImpl (Creation) + * - TransactionImpl (Creation, AddAndCommit, Rollback) + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI Builder Tests +// ============================================================================= + +class FFIBuilderTest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// SequenceImpl Tests +// ============================================================================= + +TEST_F(FFIBuilderTest, SequenceImplCreation) { + auto* seq = new SequenceImpl(); + EXPECT_EQ(seq->GetType(), GOPHER_ORCH_TYPE_SEQUENCE); + EXPECT_TRUE(seq->steps.empty()); + seq->Release(); +} + +// ============================================================================= +// ParallelImpl Tests +// ============================================================================= + +TEST_F(FFIBuilderTest, ParallelImplCreation) { + auto* parallel = new ParallelImpl(); + EXPECT_EQ(parallel->GetType(), GOPHER_ORCH_TYPE_PARALLEL); + EXPECT_TRUE(parallel->branches.empty()); + parallel->Release(); +} + +// ============================================================================= +// RouterImpl Tests +// ============================================================================= + +TEST_F(FFIBuilderTest, RouterImplCreation) { + auto* router = new RouterImpl(); + EXPECT_EQ(router->GetType(), GOPHER_ORCH_TYPE_ROUTER); + EXPECT_TRUE(router->routes.empty()); + EXPECT_EQ(router->default_route, nullptr); + router->Release(); +} + +// ============================================================================= +// TransactionImpl Tests +// ============================================================================= + +TEST_F(FFIBuilderTest, TransactionImplCreation) { + auto* txn = new TransactionImpl(nullptr); + EXPECT_EQ(txn->GetType(), GOPHER_ORCH_TYPE_TRANSACTION); + EXPECT_EQ(txn->Size(), 0); + txn->Release(); +} + +TEST_F(FFIBuilderTest, TransactionImplAddAndCommit) { + auto* txn = new TransactionImpl(nullptr); + auto* json = new JsonImpl(core::JsonValue::object()); + + auto result = txn->Add(json, GOPHER_ORCH_TYPE_JSON); + EXPECT_EQ(result, GOPHER_ORCH_OK); + EXPECT_EQ(txn->Size(), 1); + + result = txn->Commit(); + EXPECT_EQ(result, GOPHER_ORCH_OK); + + /* After commit, json handle is still valid (ownership transferred) */ + json->Release(); + txn->Release(); +} + +TEST_F(FFIBuilderTest, TransactionImplRollback) { + auto* txn = new TransactionImpl(nullptr); + + /* Track a handle - it will be cleaned up on rollback */ + size_t initial_count = HandleRegistry::Instance().GetActiveCount(); + auto* json = new JsonImpl(core::JsonValue::object()); + EXPECT_EQ(HandleRegistry::Instance().GetActiveCount(), initial_count + 1); + + txn->Add(json, GOPHER_ORCH_TYPE_JSON); + txn->Rollback(); + + /* After rollback, json should be released */ + EXPECT_EQ(HandleRegistry::Instance().GetActiveCount(), initial_count); + + txn->Release(); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_core_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_core_test.cc new file mode 100644 index 00000000..ad738d6e --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_core_test.cc @@ -0,0 +1,93 @@ +/** + * @file ffi_core_test.cc + * @brief Unit tests for FFI core components + * + * Tests: + * - DispatcherImpl (Creation, Post) + * - ConfigImpl (Creation, WithTag) + * - CancelTokenImpl (Creation, Cancel) + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI Core Tests +// ============================================================================= + +class FFICoreTest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// DispatcherImpl Tests +// ============================================================================= + +TEST_F(FFICoreTest, DispatcherImplCreation) { + auto* dispatcher = new DispatcherImpl(); + EXPECT_NE(dispatcher->dispatcher, nullptr); + EXPECT_EQ(dispatcher->GetType(), GOPHER_ORCH_TYPE_DISPATCHER); + dispatcher->Release(); +} + +TEST_F(FFICoreTest, DispatcherImplPost) { + auto* dispatcher = new DispatcherImpl(); + std::atomic executed{false}; + + dispatcher->dispatcher->post([&executed]() { executed.store(true); }); + dispatcher->dispatcher->run(mcp::event::RunType::NonBlock); + + EXPECT_TRUE(executed.load()); + dispatcher->Release(); +} + +// ============================================================================= +// ConfigImpl Tests +// ============================================================================= + +TEST_F(FFICoreTest, ConfigImplCreation) { + auto* config = new ConfigImpl(); + EXPECT_EQ(config->GetType(), GOPHER_ORCH_TYPE_CONFIG); + config->Release(); +} + +TEST_F(FFICoreTest, ConfigImplWithTag) { + auto* config = new ConfigImpl(); + config->config.withTag("key", "value"); + EXPECT_TRUE(config->config.tag("key").has_value()); + EXPECT_EQ(config->config.tag("key").value(), "value"); + config->Release(); +} + +// ============================================================================= +// CancelTokenImpl Tests +// ============================================================================= + +TEST_F(FFICoreTest, CancelTokenImplCreation) { + auto* token = new CancelTokenImpl(); + EXPECT_EQ(token->GetType(), GOPHER_ORCH_TYPE_CANCEL_TOKEN); + EXPECT_FALSE(token->cancelled.load()); + token->Release(); +} + +TEST_F(FFICoreTest, CancelTokenImplCancel) { + auto* token = new CancelTokenImpl(); + EXPECT_FALSE(token->cancelled.load()); + + token->cancelled.store(true); + EXPECT_TRUE(token->cancelled.load()); + + token->Release(); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_error_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_error_test.cc new file mode 100644 index 00000000..63e6efb2 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_error_test.cc @@ -0,0 +1,95 @@ +/** + * @file ffi_error_test.cc + * @brief Unit tests for FFI error handling + * + * Tests: + * - ErrorManager SetAndGet + * - ErrorManager Clear + * - ErrorManager GetName + * - Error scope pattern + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI Error Tests +// ============================================================================= + +class FFIErrorTest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// Error Manager Tests +// ============================================================================= + +TEST_F(FFIErrorTest, ErrorManagerSetAndGet) { + ErrorManager::SetError(GOPHER_ORCH_ERROR_INVALID_ARGUMENT, "Test error", + "Detail info"); + + auto* info = ErrorManager::GetLastError(); + ASSERT_NE(info, nullptr); + EXPECT_EQ(info->code, GOPHER_ORCH_ERROR_INVALID_ARGUMENT); + EXPECT_STREQ(info->message, "Test error"); + EXPECT_STREQ(info->details, "Detail info"); +} + +TEST_F(FFIErrorTest, ErrorManagerClear) { + ErrorManager::SetError(GOPHER_ORCH_ERROR_TIMEOUT, "Error"); + EXPECT_NE(ErrorManager::GetLastError(), nullptr); + + ErrorManager::ClearError(); + EXPECT_EQ(ErrorManager::GetLastError(), nullptr); +} + +TEST_F(FFIErrorTest, ErrorManagerGetName) { + EXPECT_STREQ(ErrorManager::GetErrorName(GOPHER_ORCH_OK), "GOPHER_ORCH_OK"); + EXPECT_STREQ(ErrorManager::GetErrorName(GOPHER_ORCH_ERROR_TIMEOUT), + "GOPHER_ORCH_ERROR_TIMEOUT"); + EXPECT_STREQ(ErrorManager::GetErrorName(GOPHER_ORCH_ERROR_CANCELLED), + "GOPHER_ORCH_ERROR_CANCELLED"); + EXPECT_STREQ(ErrorManager::GetErrorName(GOPHER_ORCH_ERROR_INVALID_HANDLE), + "GOPHER_ORCH_ERROR_INVALID_HANDLE"); + EXPECT_STREQ( + ErrorManager::GetErrorName(static_cast(-9999)), + "GOPHER_ORCH_ERROR_UNKNOWN"); +} + +// ============================================================================= +// Error Scope Pattern Tests +// ============================================================================= + +TEST_F(FFIErrorTest, ErrorScopePattern) { + /* Test the error scope pattern using ErrorManager directly */ + ErrorManager::SetError(GOPHER_ORCH_ERROR_TIMEOUT, "Pre-existing error"); + + { + /* Clear error on entry (what ErrorScope does) */ + ErrorManager::ClearError(); + + /* Verify error is cleared */ + EXPECT_EQ(ErrorManager::GetLastError(), nullptr); + + /* Set a new error */ + ErrorManager::SetError(GOPHER_ORCH_ERROR_CANCELLED, "New error"); + + /* Verify new error */ + auto* info = ErrorManager::GetLastError(); + ASSERT_NE(info, nullptr); + EXPECT_EQ(info->code, GOPHER_ORCH_ERROR_CANCELLED); + EXPECT_STREQ(info->message, "New error"); + } +} diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_handle_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_handle_test.cc new file mode 100644 index 00000000..442de2ff --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_handle_test.cc @@ -0,0 +1,159 @@ +/** + * @file ffi_handle_test.cc + * @brief Unit tests for FFI handle management + * + * Tests: + * - Handle registry (Basic, InvalidHandle, Stats) + * - Handle base (RefCounting) + * - GuardImpl (Creation, WithCleanup, Release) + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI Handle Tests +// ============================================================================= + +class FFIHandleTest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// Handle Registry Tests +// ============================================================================= + +TEST_F(FFIHandleTest, HandleRegistryBasic) { + size_t initial_count = HandleRegistry::Instance().GetActiveCount(); + + { + /* Create a JsonImpl handle */ + auto* json = new JsonImpl(core::JsonValue::object()); + EXPECT_EQ(HandleRegistry::Instance().GetActiveCount(), initial_count + 1); + EXPECT_TRUE(HandleRegistry::Instance().IsValid(json)); + + json->Release(); + } + + EXPECT_EQ(HandleRegistry::Instance().GetActiveCount(), initial_count); +} + +TEST_F(FFIHandleTest, HandleRegistryInvalidHandle) { + EXPECT_FALSE(HandleRegistry::Instance().IsValid(nullptr)); + EXPECT_FALSE( + HandleRegistry::Instance().IsValid(reinterpret_cast(0x1234))); +} + +TEST_F(FFIHandleTest, HandleRegistryStats) { + auto stats_before = HandleRegistry::Instance().GetStats(); + + { + auto* json = new JsonImpl(core::JsonValue::null()); + json->Release(); + } + + auto stats_after = HandleRegistry::Instance().GetStats(); + EXPECT_EQ(stats_after.total_created, stats_before.total_created + 1); + EXPECT_EQ(stats_after.total_destroyed, stats_before.total_destroyed + 1); +} + +// ============================================================================= +// Handle Base Tests +// ============================================================================= + +TEST_F(FFIHandleTest, HandleBaseRefCounting) { + auto* json = new JsonImpl(core::JsonValue::object()); + EXPECT_EQ(json->GetRefCount(), 1); + EXPECT_EQ(json->GetType(), GOPHER_ORCH_TYPE_JSON); + + json->AddRef(); + EXPECT_EQ(json->GetRefCount(), 2); + + json->Release(); + EXPECT_EQ(json->GetRefCount(), 1); + + json->Release(); /* Should delete */ +} + +// ============================================================================= +// GuardImpl Tests +// ============================================================================= + +TEST_F(FFIHandleTest, GuardImplCreation) { + /* Test that GuardImpl is created with correct type */ + auto* guard = new GuardImpl(reinterpret_cast(0x1234), + GOPHER_ORCH_TYPE_JSON, nullptr); + + EXPECT_EQ(guard->GetType(), GOPHER_ORCH_TYPE_GUARD); + EXPECT_EQ(guard->handle_, reinterpret_cast(0x1234)); + EXPECT_EQ(guard->type_, GOPHER_ORCH_TYPE_JSON); + EXPECT_EQ(guard->cleanup_, nullptr); + EXPECT_FALSE(guard->released_); + + /* Use HandleBase::Release to decrement refcount and delete */ + guard->HandleBase::Release(); +} + +TEST_F(FFIHandleTest, GuardImplWithCleanup) { + /* Test cleanup function is called when guard is destroyed */ + static bool cleanup_called = false; + static void* cleanup_ptr = nullptr; + + /* Use a struct to hold the state and provide a static function */ + struct CleanupState { + static void cleanup(void* ptr) { + cleanup_called = true; + cleanup_ptr = ptr; + } + }; + + cleanup_called = false; + cleanup_ptr = nullptr; + + { + auto* guard = new GuardImpl(reinterpret_cast(0x5678), + GOPHER_ORCH_TYPE_JSON, CleanupState::cleanup); + + EXPECT_EQ(guard->GetRefCount(), 1); + /* Use HandleBase::Release to decrement refcount and trigger destructor */ + guard->HandleBase::Release(); + } + + EXPECT_TRUE(cleanup_called); + EXPECT_EQ(cleanup_ptr, reinterpret_cast(0x5678)); +} + +/* Static for GuardImplRelease test */ +static bool g_guard_release_cleanup_called = false; + +static void guard_release_cleanup_fn(void*) { + g_guard_release_cleanup_called = true; +} + +TEST_F(FFIHandleTest, GuardImplRelease) { + g_guard_release_cleanup_called = false; + + auto* guard = + new GuardImpl(reinterpret_cast(0x5678), GOPHER_ORCH_TYPE_UNKNOWN, + guard_release_cleanup_fn); + + void* ptr = guard->Release(); + EXPECT_EQ(ptr, reinterpret_cast(0x5678)); + + guard->HandleBase::Release(); + + /* Cleanup should NOT be called since we released ownership */ + EXPECT_FALSE(g_guard_release_cleanup_called); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_json_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_json_test.cc new file mode 100644 index 00000000..b619e0f9 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_json_test.cc @@ -0,0 +1,90 @@ +/** + * @file ffi_json_test.cc + * @brief Unit tests for FFI JSON handling + * + * Tests: + * - JsonImpl (Null, Object, Array) + * - IteratorImpl (ObjectIteration, ArrayIteration) + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI JSON Tests +// ============================================================================= + +class FFIJsonTest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// JsonImpl Tests +// ============================================================================= + +TEST_F(FFIJsonTest, JsonImplNull) { + auto* json = new JsonImpl(core::JsonValue::null()); + EXPECT_TRUE(json->value.isNull()); + json->Release(); +} + +TEST_F(FFIJsonTest, JsonImplObject) { + auto* json = new JsonImpl(core::JsonValue::object()); + EXPECT_TRUE(json->value.isObject()); + json->value["key"] = core::JsonValue("value"); + EXPECT_EQ(json->value["key"].getString(), "value"); + json->Release(); +} + +TEST_F(FFIJsonTest, JsonImplArray) { + auto* json = new JsonImpl(core::JsonValue::array()); + EXPECT_TRUE(json->value.isArray()); + json->value.push_back(core::JsonValue(1)); + json->value.push_back(core::JsonValue(2)); + EXPECT_EQ(json->value.size(), 2); + json->Release(); +} + +// ============================================================================= +// IteratorImpl Tests +// ============================================================================= + +TEST_F(FFIJsonTest, IteratorImplObjectIteration) { + auto* json = new JsonImpl(core::JsonValue::object()); + json->value["a"] = core::JsonValue(1); + json->value["b"] = core::JsonValue(2); + + auto* iter = new IteratorImpl(reinterpret_cast(json)); + EXPECT_EQ(iter->GetType(), GOPHER_ORCH_TYPE_ITERATOR); + EXPECT_TRUE(iter->is_object_); + EXPECT_EQ(iter->object_keys_.size(), 2); + + iter->Release(); + json->Release(); +} + +TEST_F(FFIJsonTest, IteratorImplArrayIteration) { + auto* json = new JsonImpl(core::JsonValue::array()); + json->value.push_back(core::JsonValue(1)); + json->value.push_back(core::JsonValue(2)); + json->value.push_back(core::JsonValue(3)); + + auto* iter = new IteratorImpl(reinterpret_cast(json)); + EXPECT_FALSE(iter->is_object_); + EXPECT_EQ(iter->array_size_, 3); + + iter->Release(); + json->Release(); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_lambda_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_lambda_test.cc new file mode 100644 index 00000000..4ac86d8f --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_lambda_test.cc @@ -0,0 +1,112 @@ +/** + * @file ffi_lambda_test.cc + * @brief Unit tests for FFI lambda and callback components + * + * Tests: + * - LambdaRunnable (Creation, WithContext, Destructor) + * - CallbackManagerImpl (Creation) + * - ApprovalHandlerImpl (Creation) + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI Lambda Tests +// ============================================================================= + +class FFILambdaTest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// LambdaRunnable Tests +// ============================================================================= + +TEST_F(FFILambdaTest, LambdaRunnableCreation) { + auto runnable = std::make_shared( + [](void*, gopher_orch_json_t input, + gopher_orch_error_t* out_error) -> gopher_orch_json_t { + (void)input; + *out_error = GOPHER_ORCH_OK; + return reinterpret_cast( + new JsonImpl(core::JsonValue(42))); + }, + nullptr, nullptr, "TestLambda"); + + EXPECT_EQ(runnable->name(), "TestLambda"); +} + +TEST_F(FFILambdaTest, LambdaRunnableWithContext) { + int context_value = 100; + + auto runnable = std::make_shared( + [](void* ctx, gopher_orch_json_t, + gopher_orch_error_t* out_error) -> gopher_orch_json_t { + int* value = static_cast(ctx); + *out_error = GOPHER_ORCH_OK; + return reinterpret_cast( + new JsonImpl(core::JsonValue(*value))); + }, + &context_value, nullptr, "ContextLambda"); + + EXPECT_EQ(runnable->name(), "ContextLambda"); +} + +TEST_F(FFILambdaTest, LambdaRunnableDestructor) { + static bool destructor_called = false; + destructor_called = false; + + { + auto runnable = std::make_shared( + [](void*, gopher_orch_json_t, + gopher_orch_error_t* out_error) -> gopher_orch_json_t { + *out_error = GOPHER_ORCH_OK; + return reinterpret_cast( + new JsonImpl(core::JsonValue::null())); + }, + reinterpret_cast(0x1234), + [](void* ctx) { + EXPECT_EQ(ctx, reinterpret_cast(0x1234)); + destructor_called = true; + }, + "DestructorLambda"); + } + + EXPECT_TRUE(destructor_called); +} + +// ============================================================================= +// CallbackManagerImpl Tests +// ============================================================================= + +TEST_F(FFILambdaTest, CallbackManagerImplCreation) { + auto* manager = new CallbackManagerImpl(); + EXPECT_EQ(manager->GetType(), GOPHER_ORCH_TYPE_CALLBACK_MANAGER); + EXPECT_NE(manager->manager, nullptr); + manager->Release(); +} + +// ============================================================================= +// ApprovalHandlerImpl Tests +// ============================================================================= + +TEST_F(FFILambdaTest, ApprovalHandlerImplCreation) { + auto handler = std::make_shared("Test approval"); + auto* impl = new ApprovalHandlerImpl(handler); + EXPECT_EQ(impl->GetType(), GOPHER_ORCH_TYPE_APPROVAL_HANDLER); + EXPECT_NE(impl->handler, nullptr); + impl->Release(); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_raii_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_raii_test.cc new file mode 100644 index 00000000..fadcaf32 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_raii_test.cc @@ -0,0 +1,236 @@ +/** + * @file ffi_raii_test.cc + * @brief Unit tests for FFI RAII utilities + * + * Tests: + * - ResourceGuard (Basic, Release, Move, Reset, Swap) + * - AllocationTransaction (Commit, Rollback, ExplicitRollback, Move) + * - ScopedCleanup (Basic, Dismiss, Execute, Move) + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_raii.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI RAII Tests +// ============================================================================= + +class FFIRaiiTest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// ResourceGuard Tests +// ============================================================================= + +TEST_F(FFIRaiiTest, ResourceGuardBasic) { + static bool released = false; + released = false; + + { + ResourceGuard guard(reinterpret_cast(0x1234), [](void* ptr) { + EXPECT_EQ(ptr, reinterpret_cast(0x1234)); + released = true; + }); + + EXPECT_TRUE(static_cast(guard)); + EXPECT_EQ(guard.get(), reinterpret_cast(0x1234)); + } + + EXPECT_TRUE(released); +} + +TEST_F(FFIRaiiTest, ResourceGuardRelease) { + static bool released = false; + released = false; + + void* ptr = nullptr; + { + ResourceGuard guard(reinterpret_cast(0x5678), + [](void*) { released = true; }); + + ptr = guard.release(); + } + + EXPECT_FALSE(released); + EXPECT_EQ(ptr, reinterpret_cast(0x5678)); +} + +TEST_F(FFIRaiiTest, ResourceGuardMove) { + static int release_count = 0; + release_count = 0; + + { + ResourceGuard guard1(reinterpret_cast(0xABCD), + [](void*) { release_count++; }); + + ResourceGuard guard2 = std::move(guard1); + + EXPECT_FALSE(static_cast(guard1)); + EXPECT_TRUE(static_cast(guard2)); + } + + EXPECT_EQ(release_count, 1); +} + +TEST_F(FFIRaiiTest, ResourceGuardReset) { + static int release_count = 0; + release_count = 0; + + ResourceGuard guard(reinterpret_cast(0x1111), + [](void*) { release_count++; }); + + guard.reset(reinterpret_cast(0x2222)); + EXPECT_EQ(release_count, 1); + EXPECT_EQ(guard.get(), reinterpret_cast(0x2222)); + + guard.reset(); + EXPECT_EQ(release_count, 2); + EXPECT_FALSE(static_cast(guard)); +} + +TEST_F(FFIRaiiTest, ResourceGuardSwap) { + ResourceGuard guard1(reinterpret_cast(0x1111), [](void*) {}); + ResourceGuard guard2(reinterpret_cast(0x2222), [](void*) {}); + + guard1.swap(guard2); + + EXPECT_EQ(guard1.get(), reinterpret_cast(0x2222)); + EXPECT_EQ(guard2.get(), reinterpret_cast(0x1111)); +} + +// ============================================================================= +// AllocationTransaction Tests +// ============================================================================= + +TEST_F(FFIRaiiTest, AllocationTransactionCommit) { + static int cleanup_count = 0; + cleanup_count = 0; + + { + AllocationTransaction txn; + txn.track(reinterpret_cast(1), [](void*) { cleanup_count++; }); + txn.track(reinterpret_cast(2), [](void*) { cleanup_count++; }); + + EXPECT_EQ(txn.size(), 2); + txn.commit(); + EXPECT_TRUE(txn.is_committed()); + } + + /* After commit, resources should NOT be cleaned up */ + EXPECT_EQ(cleanup_count, 0); +} + +TEST_F(FFIRaiiTest, AllocationTransactionRollback) { + static int cleanup_count = 0; + cleanup_count = 0; + + { + AllocationTransaction txn; + txn.track(reinterpret_cast(1), [](void*) { cleanup_count++; }); + txn.track(reinterpret_cast(2), [](void*) { cleanup_count++; }); + /* No commit - should rollback on destruction */ + } + + /* After rollback, all resources should be cleaned up */ + EXPECT_EQ(cleanup_count, 2); +} + +TEST_F(FFIRaiiTest, AllocationTransactionExplicitRollback) { + static int cleanup_count = 0; + cleanup_count = 0; + + AllocationTransaction txn; + txn.track(reinterpret_cast(1), [](void*) { cleanup_count++; }); + txn.track(reinterpret_cast(2), [](void*) { cleanup_count++; }); + + txn.rollback(); + EXPECT_EQ(cleanup_count, 2); + EXPECT_EQ(txn.size(), 0); + EXPECT_TRUE( + txn.is_committed()); /* Marked as committed to prevent double cleanup */ +} + +TEST_F(FFIRaiiTest, AllocationTransactionMove) { + static int cleanup_count = 0; + cleanup_count = 0; + + { + AllocationTransaction txn1; + txn1.track(reinterpret_cast(1), [](void*) { cleanup_count++; }); + + AllocationTransaction txn2 = std::move(txn1); + EXPECT_EQ(txn2.size(), 1); + /* txn1 should not cleanup since ownership moved */ + } + + EXPECT_EQ(cleanup_count, 1); /* Only txn2 cleaned up */ +} + +// ============================================================================= +// ScopedCleanup Tests +// ============================================================================= + +TEST_F(FFIRaiiTest, ScopedCleanupBasic) { + static bool cleaned = false; + cleaned = false; + + { + ScopedCleanup cleanup([&]() { cleaned = true; }); + } + + EXPECT_TRUE(cleaned); +} + +TEST_F(FFIRaiiTest, ScopedCleanupDismiss) { + static bool cleaned = false; + cleaned = false; + + { + ScopedCleanup cleanup([&]() { cleaned = true; }); + cleanup.dismiss(); + } + + EXPECT_FALSE(cleaned); +} + +TEST_F(FFIRaiiTest, ScopedCleanupExecute) { + static bool cleaned = false; + cleaned = false; + + { + ScopedCleanup cleanup([&]() { cleaned = true; }); + cleanup.execute(); + EXPECT_TRUE(cleaned); + } + + /* Should not execute twice */ + cleaned = false; + /* Destructor runs but cleanup was already dismissed */ +} + +TEST_F(FFIRaiiTest, ScopedCleanupMove) { + static int cleanup_count = 0; + cleanup_count = 0; + + { + ScopedCleanup cleanup1([&]() { cleanup_count++; }); + ScopedCleanup cleanup2 = std::move(cleanup1); + /* cleanup1 should not cleanup since ownership moved */ + } + + EXPECT_EQ(cleanup_count, 1); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_types_test.cc b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_types_test.cc new file mode 100644 index 00000000..ec87cfc0 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/FFI/ffi_types_test.cc @@ -0,0 +1,125 @@ +/** + * @file ffi_types_test.cc + * @brief Unit tests for FFI type definitions and configuration structures + * + * Tests: + * - Version macros + * - Boolean constants + * - Error code values + * - Type ID values + * - Channel type values + * - Transport type values + * - Configuration structures (RetryPolicy, CircuitBreaker, McpConfig, etc.) + */ + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI Type Tests +// ============================================================================= + +class FFITypesTest : public OrchTest {}; + +// ============================================================================= +// Version and Constant Tests +// ============================================================================= + +TEST_F(FFITypesTest, VersionMacros) { + EXPECT_GE(GOPHER_ORCH_VERSION_MAJOR, 1); + EXPECT_GE(GOPHER_ORCH_VERSION_MINOR, 0); + EXPECT_GE(GOPHER_ORCH_VERSION_PATCH, 0); +} + +TEST_F(FFITypesTest, BooleanConstants) { + EXPECT_EQ(GOPHER_ORCH_FALSE, 0); + EXPECT_NE(GOPHER_ORCH_TRUE, 0); +} + +TEST_F(FFITypesTest, ErrorCodeValues) { + EXPECT_EQ(GOPHER_ORCH_OK, 0); + EXPECT_LT(GOPHER_ORCH_ERROR_INVALID_HANDLE, 0); + EXPECT_LT(GOPHER_ORCH_ERROR_INVALID_ARGUMENT, 0); + EXPECT_LT(GOPHER_ORCH_ERROR_NULL_POINTER, 0); + EXPECT_LT(GOPHER_ORCH_ERROR_NOT_FOUND, 0); + EXPECT_LT(GOPHER_ORCH_ERROR_TIMEOUT, 0); + EXPECT_LT(GOPHER_ORCH_ERROR_CANCELLED, 0); +} + +TEST_F(FFITypesTest, TypeIdValues) { + EXPECT_NE(GOPHER_ORCH_TYPE_DISPATCHER, GOPHER_ORCH_TYPE_RUNNABLE); + EXPECT_NE(GOPHER_ORCH_TYPE_JSON, GOPHER_ORCH_TYPE_CONFIG); + EXPECT_NE(GOPHER_ORCH_TYPE_FSM, GOPHER_ORCH_TYPE_GRAPH); +} + +TEST_F(FFITypesTest, ChannelTypeValues) { + EXPECT_EQ(GOPHER_ORCH_CHANNEL_LAST_VALUE, 0); + EXPECT_EQ(GOPHER_ORCH_CHANNEL_APPEND_LIST, 1); + EXPECT_EQ(GOPHER_ORCH_CHANNEL_MERGE_OBJECT, 2); +} + +TEST_F(FFITypesTest, TransportTypeValues) { + EXPECT_EQ(GOPHER_ORCH_TRANSPORT_STDIO, 0); + EXPECT_EQ(GOPHER_ORCH_TRANSPORT_SSE, 1); + EXPECT_EQ(GOPHER_ORCH_TRANSPORT_WEBSOCKET, 2); +} + +// ============================================================================= +// Configuration Structure Tests +// ============================================================================= + +TEST_F(FFITypesTest, RetryPolicyStructure) { + gopher_orch_retry_policy_t policy = {}; + policy.max_attempts = 3; + policy.initial_delay_ms = 100; + policy.backoff_multiplier = 2.0; + policy.max_delay_ms = 1000; + policy.jitter = GOPHER_ORCH_TRUE; + + EXPECT_EQ(policy.max_attempts, 3); + EXPECT_EQ(policy.initial_delay_ms, 100); + EXPECT_DOUBLE_EQ(policy.backoff_multiplier, 2.0); + EXPECT_EQ(policy.max_delay_ms, 1000); + EXPECT_EQ(policy.jitter, GOPHER_ORCH_TRUE); +} + +TEST_F(FFITypesTest, CircuitBreakerPolicyStructure) { + gopher_orch_circuit_breaker_policy_t policy = {}; + policy.failure_threshold = 5; + policy.recovery_timeout_ms = 30000; + policy.half_open_max_calls = 1; + + EXPECT_EQ(policy.failure_threshold, 5); + EXPECT_EQ(policy.recovery_timeout_ms, 30000); + EXPECT_EQ(policy.half_open_max_calls, 1); +} + +TEST_F(FFITypesTest, McpConfigStructure) { + gopher_orch_mcp_config_t config = {}; + + config.name = "test-server"; + config.transport = GOPHER_ORCH_TRANSPORT_STDIO; + config.command = "/usr/bin/echo"; + config.connect_timeout_ms = 5000; + config.request_timeout_ms = 30000; + + EXPECT_STREQ(config.name, "test-server"); + EXPECT_EQ(config.transport, GOPHER_ORCH_TRANSPORT_STDIO); + EXPECT_STREQ(config.command, "/usr/bin/echo"); + EXPECT_EQ(config.connect_timeout_ms, 5000); + EXPECT_EQ(config.request_timeout_ms, 30000); +} + +TEST_F(FFITypesTest, TransactionOptsStructure) { + gopher_orch_transaction_opts_t opts = {}; + opts.auto_rollback = GOPHER_ORCH_TRUE; + opts.strict_ordering = GOPHER_ORCH_TRUE; + opts.max_resources = 100; + + EXPECT_EQ(opts.auto_rollback, GOPHER_ORCH_TRUE); + EXPECT_EQ(opts.strict_ordering, GOPHER_ORCH_TRUE); + EXPECT_EQ(opts.max_resources, 100); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/agent_runnable_test.cc b/third_party/gopher-orch/tests/gopher/orch/agent_runnable_test.cc new file mode 100644 index 00000000..8819e32c --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/agent_runnable_test.cc @@ -0,0 +1,483 @@ +// Unit tests for AgentRunnable + +#include "gopher/orch/agent/agent_runnable.h" + +#include "mock_llm_provider.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +// ============================================================================= +// AgentRunnable Test Fixture +// ============================================================================= + +class AgentRunnableTest : public OrchTest { + protected: + std::shared_ptr mock_provider_; + ToolRegistryPtr registry_; + ToolExecutorPtr executor_; + AgentRunnable::Ptr agent_; + + void SetUp() override { + OrchTest::SetUp(); + mock_provider_ = makeMockLLMProvider("test-llm"); + registry_ = makeToolRegistry(); + executor_ = makeToolExecutor(registry_); + + addTestTools(); + + agent_ = AgentRunnable::create( + mock_provider_, executor_, + AgentConfig("gpt-4").withSystemPrompt("You are a helpful assistant.")); + } + + void addTestTools() { + // Search tool + registry_->addTool( + "search", "Search the web", JsonValue::object(), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + std::string query = "default"; + if (args.contains("query") && args["query"].isString()) { + query = args["query"].getString(); + } + + JsonValue result = JsonValue::object(); + result["query"] = query; + result["answer"] = "Search result for: " + query; + + d.post([cb = std::move(cb), result = std::move(result)]() mutable { + cb(Result(std::move(result))); + }); + }); + + // Calculator tool + registry_->addSyncTool( + "calculator", "Perform calculations", JsonValue::object(), + [](const JsonValue& args) -> Result { + if (args.contains("expression") && args["expression"].isString()) { + std::string expr = args["expression"].getString(); + if (expr == "2+2") { + return Result(JsonValue(4)); + } + } + return Result(JsonValue(0)); + }); + } +}; + +// ============================================================================= +// Basic Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, Name) { EXPECT_EQ(agent_->name(), "AgentRunnable"); } + +TEST_F(AgentRunnableTest, Accessors) { + EXPECT_EQ(agent_->provider(), mock_provider_); + EXPECT_EQ(agent_->executor(), executor_); + EXPECT_EQ(agent_->registry(), registry_); +} + +// ============================================================================= +// Simple Query Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, SimpleQueryNoTools) { + mock_provider_->setDefaultResponse("Hello! How can I help you?"); + + JsonValue input = "Hi there!"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result.isObject()); + EXPECT_EQ(result["status"].getString(), "completed"); + EXPECT_EQ(result["response"].getString(), "Hello! How can I help you?"); + EXPECT_EQ(result["iterations"].getInt(), 1); + + // Check messages include system prompt + auto last_msgs = mock_provider_->lastMessages(); + EXPECT_GE(last_msgs.size(), 2u); + EXPECT_EQ(last_msgs[0].role, Role::SYSTEM); + EXPECT_EQ(last_msgs[0].content, "You are a helpful assistant."); +} + +TEST_F(AgentRunnableTest, QueryObjectInput) { + mock_provider_->setDefaultResponse("The weather is sunny."); + + JsonValue input = JsonValue::object(); + input["query"] = "What is the weather?"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["status"].getString(), "completed"); + EXPECT_EQ(result["response"].getString(), "The weather is sunny."); +} + +// ============================================================================= +// Tool Usage Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, SingleToolCall) { + // First response: call search tool + std::vector tool_calls; + JsonValue args = JsonValue::object(); + args["query"] = "weather in tokyo"; + tool_calls.push_back(ToolCall("call_1", "search", args)); + mock_provider_->queueToolCalls(tool_calls); + + // Second response: final answer + mock_provider_->queueResponse( + "Based on the search, the weather in Tokyo is sunny."); + + JsonValue input = "What is the weather in Tokyo?"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["status"].getString(), "completed"); + EXPECT_EQ(result["response"].getString(), + "Based on the search, the weather in Tokyo is sunny."); + EXPECT_EQ(result["iterations"].getInt(), 2); + + // Verify tool results were added to conversation + EXPECT_TRUE(result["messages"].isArray()); + bool found_tool_result = false; + for (size_t i = 0; i < result["messages"].size(); ++i) { + if (result["messages"][i]["role"].getString() == "tool") { + found_tool_result = true; + break; + } + } + EXPECT_TRUE(found_tool_result); +} + +TEST_F(AgentRunnableTest, MultipleToolCalls) { + // First response: call two tools + std::vector tool_calls; + JsonValue args1 = JsonValue::object(); + args1["query"] = "weather"; + tool_calls.push_back(ToolCall("call_1", "search", args1)); + + JsonValue args2 = JsonValue::object(); + args2["expression"] = "2+2"; + tool_calls.push_back(ToolCall("call_2", "calculator", args2)); + + mock_provider_->queueToolCalls(tool_calls); + + // Second response: final answer + mock_provider_->queueResponse("I found weather info and calculated 2+2=4."); + + JsonValue input = "Search weather and calculate 2+2"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["status"].getString(), "completed"); + EXPECT_EQ(result["iterations"].getInt(), 2); +} + +// ============================================================================= +// Configuration Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, ConfigOverridesInInput) { + mock_provider_->setDefaultResponse("OK"); + + JsonValue input = JsonValue::object(); + input["query"] = "Test"; + + JsonValue config = JsonValue::object(); + config["system_prompt"] = "Custom system prompt"; + config["model"] = "gpt-3.5-turbo"; + input["config"] = config; + + runToCompletion([&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + auto last_msgs = mock_provider_->lastMessages(); + EXPECT_EQ(last_msgs[0].content, "Custom system prompt"); + EXPECT_EQ(mock_provider_->lastConfig().model, "gpt-3.5-turbo"); +} + +TEST_F(AgentRunnableTest, MaxIterations) { + // Set up agent to always call tools (never complete) + for (int i = 0; i < 15; ++i) { + std::vector calls; + JsonValue args = JsonValue::object(); + args["query"] = "test"; + calls.push_back(ToolCall("call_" + std::to_string(i), "search", args)); + mock_provider_->queueToolCalls(calls); + } + + // Create agent with low max iterations + auto limited_agent = AgentRunnable::create( + mock_provider_, executor_, AgentConfig("gpt-4").withMaxIterations(3)); + + JsonValue input = "Test query"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + limited_agent->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["status"].getString(), "max_iterations_reached"); + EXPECT_EQ(result["iterations"].getInt(), 3); +} + +// ============================================================================= +// Context Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, WithContext) { + mock_provider_->setDefaultResponse("I remember you asked about weather."); + + JsonValue input = JsonValue::object(); + input["query"] = "What did I ask before?"; + + JsonValue context = JsonValue::array(); + JsonValue msg1 = JsonValue::object(); + msg1["role"] = "user"; + msg1["content"] = "What is the weather?"; + context.push_back(msg1); + + JsonValue msg2 = JsonValue::object(); + msg2["role"] = "assistant"; + msg2["content"] = "The weather is sunny."; + context.push_back(msg2); + + input["context"] = context; + + runToCompletion([&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // Verify context was included + auto last_msgs = mock_provider_->lastMessages(); + EXPECT_GE(last_msgs.size(), 4u); // system + 2 context + query + EXPECT_EQ(last_msgs[1].content, "What is the weather?"); + EXPECT_EQ(last_msgs[2].content, "The weather is sunny."); +} + +TEST_F(AgentRunnableTest, LangGraphStyleInput) { + mock_provider_->setDefaultResponse("I understand."); + + JsonValue input = JsonValue::object(); + JsonValue messages = JsonValue::array(); + + JsonValue msg = JsonValue::object(); + msg["role"] = "user"; + msg["content"] = "Hello from messages array"; + messages.push_back(msg); + + input["messages"] = messages; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["status"].getString(), "completed"); + + // Verify message was used + auto last_msgs = mock_provider_->lastMessages(); + bool found = false; + for (const auto& m : last_msgs) { + if (m.content == "Hello from messages array") { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +// ============================================================================= +// Callback Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, StepCallback) { + // First call: tool call + std::vector calls; + JsonValue args = JsonValue::object(); + args["query"] = "test"; + calls.push_back(ToolCall("call_1", "search", args)); + mock_provider_->queueToolCalls(calls); + + // Second call: final response + mock_provider_->queueResponse("Done!"); + + std::vector recorded_steps; + agent_->setStepCallback([&recorded_steps](const AgentStep& step) { + recorded_steps.push_back(step); + }); + + JsonValue input = "Test"; + + runToCompletion([&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(recorded_steps.size(), 2u); + EXPECT_EQ(recorded_steps[0].step_number, 1); + EXPECT_EQ(recorded_steps[1].step_number, 2); +} + +TEST_F(AgentRunnableTest, ToolApprovalCallback) { + std::vector calls; + JsonValue args = JsonValue::object(); + args["query"] = "test"; + calls.push_back(ToolCall("call_1", "search", args)); + mock_provider_->queueToolCalls(calls); + + // Reject all tool calls + agent_->setToolApprovalCallback([](const ToolCall& call) { + return false; // Reject + }); + + JsonValue input = "Test"; + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, AgentError::CANCELLED); +} + +// ============================================================================= +// Error Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, NoProviderError) { + auto agent_no_provider = AgentRunnable::create(nullptr, AgentConfig("gpt-4")); + + JsonValue input = "Test"; + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + agent_no_provider->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, AgentError::NO_PROVIDER); +} + +TEST_F(AgentRunnableTest, EmptyInput) { + JsonValue input = JsonValue::object(); + // No query or messages + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); +} + +TEST_F(AgentRunnableTest, LLMError) { + mock_provider_->queueError(LLMError::RATE_LIMITED, "Rate limit exceeded"); + + JsonValue input = "Test"; + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, LLMError::RATE_LIMITED); +} + +TEST_F(AgentRunnableTest, AgentWithoutTools) { + // Create agent without tools + auto agent_no_tools = AgentRunnable::create( + mock_provider_, + AgentConfig("gpt-4").withSystemPrompt("You are helpful.")); + + // LLM tries to call a tool anyway + std::vector calls; + JsonValue args = JsonValue::object(); + calls.push_back(ToolCall("call_1", "search", args)); + mock_provider_->queueToolCalls(calls); + + // LLM handles the error gracefully + mock_provider_->queueResponse("I cannot search, but I can help otherwise."); + + JsonValue input = "Search for something"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_no_tools->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["status"].getString(), "completed"); +} + +// ============================================================================= +// Output Structure Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, OutputContainsUsage) { + LLMResponse response; + response.message = Message::assistant("Test response"); + response.finish_reason = "stop"; + response.usage = Usage(100, 50); + mock_provider_->queueFullResponse(response); + + JsonValue input = "Test"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result.contains("usage")); + EXPECT_EQ(result["usage"]["prompt_tokens"].getInt(), 100); + EXPECT_EQ(result["usage"]["completion_tokens"].getInt(), 50); + EXPECT_EQ(result["usage"]["total_tokens"].getInt(), 150); +} + +TEST_F(AgentRunnableTest, OutputContainsDuration) { + mock_provider_->setDefaultResponse("Quick response"); + + JsonValue input = "Test"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result.contains("duration_ms")); + EXPECT_GE(result["duration_ms"].getInt(), 0); +} + +// ============================================================================= +// Factory Function Tests +// ============================================================================= + +TEST_F(AgentRunnableTest, MakeAgentRunnableWithRegistry) { + auto agent = + makeAgentRunnable(mock_provider_, registry_, AgentConfig("gpt-4")); + EXPECT_NE(agent, nullptr); + EXPECT_EQ(agent->provider(), mock_provider_); + EXPECT_EQ(agent->registry(), registry_); +} + +TEST_F(AgentRunnableTest, MakeAgentRunnableWithoutTools) { + auto agent = makeAgentRunnable(mock_provider_, AgentConfig("gpt-4")); + EXPECT_NE(agent, nullptr); + EXPECT_EQ(agent->provider(), mock_provider_); + EXPECT_EQ(agent->registry(), nullptr); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/agent_state_test.cc b/third_party/gopher-orch/tests/gopher/orch/agent_state_test.cc new file mode 100644 index 00000000..289354d5 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/agent_state_test.cc @@ -0,0 +1,392 @@ +// Unit tests for AgentState reducer and JSON serialization + +#include "gopher/orch/agent/agent_types.h" +#include "gtest/gtest.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +// ============================================================================= +// AgentState Reducer Tests +// ============================================================================= + +TEST(AgentStateReducerTest, MessagesAppend) { + AgentState current; + current.messages.push_back(Message::user("Hello")); + current.messages.push_back(Message::assistant("Hi there!")); + + AgentState update; + update.messages.push_back(Message::user("How are you?")); + + auto result = AgentState::reduce(current, update); + + EXPECT_EQ(result.messages.size(), 3u); + EXPECT_EQ(result.messages[0].content, "Hello"); + EXPECT_EQ(result.messages[1].content, "Hi there!"); + EXPECT_EQ(result.messages[2].content, "How are you?"); +} + +TEST(AgentStateReducerTest, StepsAppend) { + AgentState current; + AgentStep step1; + step1.step_number = 1; + step1.llm_message = Message::assistant("First response"); + current.steps.push_back(step1); + + AgentState update; + AgentStep step2; + step2.step_number = 2; + step2.llm_message = Message::assistant("Second response"); + update.steps.push_back(step2); + + auto result = AgentState::reduce(current, update); + + EXPECT_EQ(result.steps.size(), 2u); + EXPECT_EQ(result.steps[0].step_number, 1); + EXPECT_EQ(result.steps[1].step_number, 2); +} + +TEST(AgentStateReducerTest, UsageAccumulates) { + AgentState current; + current.total_usage.prompt_tokens = 100; + current.total_usage.completion_tokens = 50; + current.total_usage.total_tokens = 150; + + AgentState update; + update.total_usage.prompt_tokens = 80; + update.total_usage.completion_tokens = 30; + update.total_usage.total_tokens = 110; + + auto result = AgentState::reduce(current, update); + + EXPECT_EQ(result.total_usage.prompt_tokens, 180); + EXPECT_EQ(result.total_usage.completion_tokens, 80); + EXPECT_EQ(result.total_usage.total_tokens, 260); +} + +TEST(AgentStateReducerTest, StatusLastWriteWins) { + AgentState current; + current.status = AgentStatus::RUNNING; + + AgentState update; + update.status = AgentStatus::COMPLETED; + + auto result = AgentState::reduce(current, update); + + EXPECT_EQ(result.status, AgentStatus::COMPLETED); +} + +TEST(AgentStateReducerTest, IterationCountsLastWriteWins) { + AgentState current; + current.current_iteration = 2; + current.remaining_steps = 8; + + AgentState update; + update.current_iteration = 3; + update.remaining_steps = 7; + + auto result = AgentState::reduce(current, update); + + EXPECT_EQ(result.current_iteration, 3); + EXPECT_EQ(result.remaining_steps, 7); +} + +TEST(AgentStateReducerTest, ErrorLastWriteWins) { + AgentState current; + current.error = Error(-1, "First error"); + + AgentState update; + update.error = Error(-2, "Second error"); + + auto result = AgentState::reduce(current, update); + + EXPECT_TRUE(result.error.has_value()); + EXPECT_EQ(result.error->code, -2); + EXPECT_EQ(result.error->message, "Second error"); +} + +TEST(AgentStateReducerTest, ClearError) { + AgentState current; + current.error = Error(-1, "Had error"); + + AgentState update; + // update.error is nullopt + + auto result = AgentState::reduce(current, update); + + EXPECT_FALSE(result.error.has_value()); +} + +TEST(AgentStateReducerTest, EmptyStates) { + AgentState current; + AgentState update; + + auto result = AgentState::reduce(current, update); + + EXPECT_TRUE(result.messages.empty()); + EXPECT_TRUE(result.steps.empty()); + EXPECT_EQ(result.status, AgentStatus::IDLE); +} + +// ============================================================================= +// AgentState JSON Serialization Tests +// ============================================================================= + +TEST(AgentStateJsonTest, ToJsonBasic) { + AgentState state; + state.status = AgentStatus::RUNNING; + state.current_iteration = 2; + state.remaining_steps = 8; + state.messages.push_back(Message::user("Hello")); + state.messages.push_back(Message::assistant("Hi!")); + state.total_usage = Usage(100, 50); + + JsonValue json = state.toJson(); + + EXPECT_TRUE(json.isObject()); + EXPECT_EQ(json["status"].getString(), "running"); + EXPECT_EQ(json["current_iteration"].getInt(), 2); + EXPECT_EQ(json["remaining_steps"].getInt(), 8); + EXPECT_TRUE(json["messages"].isArray()); + EXPECT_EQ(json["messages"].size(), 2u); + EXPECT_EQ(json["messages"][0]["role"].getString(), "user"); + EXPECT_EQ(json["messages"][0]["content"].getString(), "Hello"); + EXPECT_EQ(json["messages"][1]["role"].getString(), "assistant"); + EXPECT_EQ(json["usage"]["prompt_tokens"].getInt(), 100); + EXPECT_EQ(json["usage"]["completion_tokens"].getInt(), 50); + EXPECT_EQ(json["usage"]["total_tokens"].getInt(), 150); +} + +TEST(AgentStateJsonTest, ToJsonWithToolCalls) { + AgentState state; + state.status = AgentStatus::RUNNING; + + std::vector calls; + JsonValue args = JsonValue::object(); + args["query"] = "test"; + calls.push_back(ToolCall("call_1", "search", args)); + state.messages.push_back(Message::assistantWithToolCalls(calls)); + + JsonValue json = state.toJson(); + + auto& msg = json["messages"][0]; + EXPECT_TRUE(msg.contains("tool_calls")); + EXPECT_TRUE(msg["tool_calls"].isArray()); + EXPECT_EQ(msg["tool_calls"].size(), 1u); + EXPECT_EQ(msg["tool_calls"][0]["id"].getString(), "call_1"); + EXPECT_EQ(msg["tool_calls"][0]["name"].getString(), "search"); + EXPECT_EQ(msg["tool_calls"][0]["arguments"]["query"].getString(), "test"); +} + +TEST(AgentStateJsonTest, ToJsonWithToolResult) { + AgentState state; + state.messages.push_back(Message::toolResult("call_1", "Result data")); + + JsonValue json = state.toJson(); + + auto& msg = json["messages"][0]; + EXPECT_EQ(msg["role"].getString(), "tool"); + EXPECT_EQ(msg["content"].getString(), "Result data"); + EXPECT_EQ(msg["tool_call_id"].getString(), "call_1"); +} + +TEST(AgentStateJsonTest, ToJsonWithError) { + AgentState state; + state.status = AgentStatus::FAILED; + state.error = Error(-1, "Something went wrong"); + + JsonValue json = state.toJson(); + + EXPECT_TRUE(json.contains("error")); + EXPECT_EQ(json["error"]["code"].getInt(), -1); + EXPECT_EQ(json["error"]["message"].getString(), "Something went wrong"); +} + +TEST(AgentStateJsonTest, FromJsonBasic) { + JsonValue json = JsonValue::object(); + json["status"] = "completed"; + json["current_iteration"] = 3; + json["remaining_steps"] = 7; + + JsonValue messages = JsonValue::array(); + JsonValue msg1 = JsonValue::object(); + msg1["role"] = "user"; + msg1["content"] = "Hello"; + messages.push_back(msg1); + + JsonValue msg2 = JsonValue::object(); + msg2["role"] = "assistant"; + msg2["content"] = "Hi there!"; + messages.push_back(msg2); + + json["messages"] = messages; + + JsonValue usage = JsonValue::object(); + usage["prompt_tokens"] = 100; + usage["completion_tokens"] = 50; + usage["total_tokens"] = 150; + json["usage"] = usage; + + AgentState state = AgentState::fromJson(json); + + EXPECT_EQ(state.status, AgentStatus::COMPLETED); + EXPECT_EQ(state.current_iteration, 3); + EXPECT_EQ(state.remaining_steps, 7); + EXPECT_EQ(state.messages.size(), 2u); + EXPECT_EQ(state.messages[0].role, Role::USER); + EXPECT_EQ(state.messages[0].content, "Hello"); + EXPECT_EQ(state.messages[1].role, Role::ASSISTANT); + EXPECT_EQ(state.total_usage.prompt_tokens, 100); + EXPECT_EQ(state.total_usage.completion_tokens, 50); +} + +TEST(AgentStateJsonTest, FromJsonWithToolCalls) { + JsonValue json = JsonValue::object(); + json["status"] = "running"; + + JsonValue messages = JsonValue::array(); + JsonValue msg = JsonValue::object(); + msg["role"] = "assistant"; + msg["content"] = ""; + + JsonValue tool_calls = JsonValue::array(); + JsonValue call = JsonValue::object(); + call["id"] = "call_123"; + call["name"] = "search"; + JsonValue args = JsonValue::object(); + args["query"] = "weather"; + call["arguments"] = args; + tool_calls.push_back(call); + msg["tool_calls"] = tool_calls; + + messages.push_back(msg); + json["messages"] = messages; + + AgentState state = AgentState::fromJson(json); + + EXPECT_EQ(state.messages.size(), 1u); + EXPECT_TRUE(state.messages[0].hasToolCalls()); + EXPECT_EQ(state.messages[0].tool_calls->size(), 1u); + EXPECT_EQ((*state.messages[0].tool_calls)[0].id, "call_123"); + EXPECT_EQ((*state.messages[0].tool_calls)[0].name, "search"); +} + +TEST(AgentStateJsonTest, FromJsonWithError) { + JsonValue json = JsonValue::object(); + json["status"] = "failed"; + + JsonValue error = JsonValue::object(); + error["code"] = -100; + error["message"] = "Rate limited"; + json["error"] = error; + + AgentState state = AgentState::fromJson(json); + + EXPECT_EQ(state.status, AgentStatus::FAILED); + EXPECT_TRUE(state.error.has_value()); + EXPECT_EQ(state.error->code, -100); + EXPECT_EQ(state.error->message, "Rate limited"); +} + +TEST(AgentStateJsonTest, RoundTrip) { + // Create a complex state + AgentState original; + original.status = AgentStatus::RUNNING; + original.current_iteration = 2; + original.remaining_steps = 8; + original.total_usage = Usage(150, 75); + + original.messages.push_back(Message::system("You are helpful")); + original.messages.push_back(Message::user("Search for weather")); + + std::vector calls; + JsonValue args = JsonValue::object(); + args["query"] = "weather tokyo"; + calls.push_back(ToolCall("call_1", "search", args)); + original.messages.push_back(Message::assistantWithToolCalls(calls)); + + original.messages.push_back(Message::toolResult("call_1", "Sunny, 25C")); + original.messages.push_back(Message::assistant("The weather is sunny.")); + + // Convert to JSON and back + JsonValue json = original.toJson(); + AgentState restored = AgentState::fromJson(json); + + // Verify + EXPECT_EQ(restored.status, original.status); + EXPECT_EQ(restored.current_iteration, original.current_iteration); + EXPECT_EQ(restored.remaining_steps, original.remaining_steps); + EXPECT_EQ(restored.total_usage.prompt_tokens, + original.total_usage.prompt_tokens); + EXPECT_EQ(restored.messages.size(), original.messages.size()); + + // Check messages + EXPECT_EQ(restored.messages[0].role, Role::SYSTEM); + EXPECT_EQ(restored.messages[1].role, Role::USER); + EXPECT_EQ(restored.messages[2].role, Role::ASSISTANT); + EXPECT_TRUE(restored.messages[2].hasToolCalls()); + EXPECT_EQ(restored.messages[3].role, Role::TOOL); + EXPECT_EQ(*restored.messages[3].tool_call_id, "call_1"); + EXPECT_EQ(restored.messages[4].content, "The weather is sunny."); +} + +TEST(AgentStateJsonTest, FromJsonInvalid) { + // Non-object input should return default state + JsonValue json = JsonValue::array(); + AgentState state = AgentState::fromJson(json); + + EXPECT_EQ(state.status, AgentStatus::IDLE); + EXPECT_TRUE(state.messages.empty()); +} + +// ============================================================================= +// AgentState Helper Method Tests +// ============================================================================= + +TEST(AgentStateTest, IsRunning) { + AgentState state; + EXPECT_FALSE(state.isRunning()); + + state.status = AgentStatus::RUNNING; + EXPECT_TRUE(state.isRunning()); + + state.status = AgentStatus::COMPLETED; + EXPECT_FALSE(state.isRunning()); +} + +TEST(AgentStateTest, IsCompleted) { + AgentState state; + EXPECT_FALSE(state.isCompleted()); + + state.status = AgentStatus::COMPLETED; + EXPECT_TRUE(state.isCompleted()); + + state.status = AgentStatus::FAILED; + EXPECT_FALSE(state.isCompleted()); +} + +TEST(AgentStateTest, LastContent) { + AgentState state; + EXPECT_EQ(state.lastContent(), ""); + + state.messages.push_back(Message::user("First")); + EXPECT_EQ(state.lastContent(), "First"); + + state.messages.push_back(Message::assistant("Second")); + EXPECT_EQ(state.lastContent(), "Second"); +} + +// ============================================================================= +// AgentStatus Tests +// ============================================================================= + +TEST(AgentStatusTest, ToString) { + EXPECT_EQ(agentStatusToString(AgentStatus::IDLE), "idle"); + EXPECT_EQ(agentStatusToString(AgentStatus::RUNNING), "running"); + EXPECT_EQ(agentStatusToString(AgentStatus::COMPLETED), "completed"); + EXPECT_EQ(agentStatusToString(AgentStatus::FAILED), "failed"); + EXPECT_EQ(agentStatusToString(AgentStatus::CANCELLED), "cancelled"); + EXPECT_EQ(agentStatusToString(AgentStatus::MAX_ITERATIONS_REACHED), + "max_iterations_reached"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/agent_test.cc b/third_party/gopher-orch/tests/gopher/orch/agent_test.cc new file mode 100644 index 00000000..9123f752 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/agent_test.cc @@ -0,0 +1,462 @@ +// Unit tests for ReActAgent + +#include "gopher/orch/agent/agent.h" + +#include "gopher/orch/agent/agent_types.h" +#include "gopher/orch/agent/tool_registry.h" +#include "mock_llm_provider.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; + +// ============================================================================= +// Agent Test Fixture +// ============================================================================= + +class AgentTest : public OrchTest { + protected: + std::shared_ptr provider_; + ToolRegistryPtr registry_; + + void SetUp() override { + OrchTest::SetUp(); + provider_ = makeMockLLMProvider("test-provider"); + registry_ = makeToolRegistry(); + } + + // Helper to run agent to completion + AgentResult runAgent(ReActAgent::Ptr agent, const std::string& query) { + return runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent->run(query, d, std::move(cb)); + }); + } + + // Helper to run agent and allow errors + Result runAgentResult(ReActAgent::Ptr agent, + const std::string& query) { + return runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + agent->run(query, d, std::move(cb)); + }); + } +}; + +// ============================================================================= +// Basic Agent Tests +// ============================================================================= + +TEST_F(AgentTest, CreateAgent) { + auto agent = ReActAgent::create(provider_, registry_); + EXPECT_NE(agent, nullptr); + EXPECT_FALSE(agent->isRunning()); + EXPECT_EQ(agent->provider(), provider_); + EXPECT_EQ(agent->tools(), registry_); +} + +TEST_F(AgentTest, CreateAgentWithConfig) { + AgentConfig config("gpt-4"); + config.withSystemPrompt("You are a helpful assistant.") + .withMaxIterations(5) + .withTemperature(0.7); + + auto agent = ReActAgent::create(provider_, registry_, config); + + EXPECT_EQ(agent->config().llm_config.model, "gpt-4"); + EXPECT_EQ(agent->config().system_prompt, "You are a helpful assistant."); + EXPECT_EQ(agent->config().max_iterations, 5); + EXPECT_TRUE(agent->config().llm_config.temperature.has_value()); + EXPECT_DOUBLE_EQ(*agent->config().llm_config.temperature, 0.7); +} + +TEST_F(AgentTest, SimpleQuery) { + provider_->setDefaultResponse("Hello! How can I help you today?"); + + AgentConfig config("test-model"); + auto agent = ReActAgent::create(provider_, registry_, config); + + auto result = runAgent(agent, "Hello"); + + EXPECT_TRUE(result.isSuccess()); + EXPECT_EQ(result.status, AgentStatus::COMPLETED); + EXPECT_EQ(result.response, "Hello! How can I help you today?"); + EXPECT_EQ(result.iterationCount(), 1); + EXPECT_EQ(provider_->callCount(), 1u); +} + +TEST_F(AgentTest, SystemPromptIncluded) { + provider_->setDefaultResponse("I am a test assistant."); + + AgentConfig config("test-model"); + config.withSystemPrompt("You are a test assistant."); + auto agent = ReActAgent::create(provider_, registry_, config); + + runAgent(agent, "Who are you?"); + + auto messages = provider_->lastMessages(); + ASSERT_GE(messages.size(), 2u); + EXPECT_EQ(messages[0].role, Role::SYSTEM); + EXPECT_EQ(messages[0].content, "You are a test assistant."); + EXPECT_EQ(messages[1].role, Role::USER); + EXPECT_EQ(messages[1].content, "Who are you?"); +} + +// ============================================================================= +// Tool Execution Tests +// ============================================================================= + +TEST_F(AgentTest, SingleToolCall) { + // First response: call search tool + ToolCall call1("call_1", "search", JsonValue::object()); + provider_->queueToolCalls({call1}); + + // Second response: final answer + provider_->queueResponse("The search found: example result."); + + // Add search tool to registry + JsonValue search_result = JsonValue::object(); + search_result["result"] = "example result"; + + registry_->addSyncTool( + "search", "Search the web", JsonValue::object(), + [search_result](const JsonValue& args) -> Result { + return Result(search_result); + }); + + auto agent = ReActAgent::create(provider_, registry_); + auto result = runAgent(agent, "Search for something"); + + EXPECT_TRUE(result.isSuccess()); + EXPECT_EQ(result.status, AgentStatus::COMPLETED); + EXPECT_EQ(result.response, "The search found: example result."); + EXPECT_EQ(result.iterationCount(), 2); // Tool call + final response + EXPECT_EQ(provider_->callCount(), 2u); +} + +TEST_F(AgentTest, MultipleToolCalls) { + // First response: call two tools + ToolCall call1("call_1", "get_weather", JsonValue::object()); + ToolCall call2("call_2", "get_time", JsonValue::object()); + provider_->queueToolCalls({call1, call2}); + + // Second response: final answer + provider_->queueResponse("It's sunny and 3pm."); + + // Add tools + registry_->addSyncTool("get_weather", "Get weather", JsonValue::object(), + [](const JsonValue& args) -> Result { + JsonValue result = JsonValue::object(); + result["weather"] = "sunny"; + return Result(result); + }); + + registry_->addSyncTool("get_time", "Get time", JsonValue::object(), + [](const JsonValue& args) -> Result { + JsonValue result = JsonValue::object(); + result["time"] = "3pm"; + return Result(result); + }); + + auto agent = ReActAgent::create(provider_, registry_); + auto result = runAgent(agent, "What's the weather and time?"); + + EXPECT_TRUE(result.isSuccess()); + EXPECT_EQ(result.iterationCount(), 2); + + // Check that both tools were called + EXPECT_GE(result.steps.size(), 1u); + if (!result.steps.empty()) { + EXPECT_EQ(result.steps[0].tool_executions.size(), 2u); + } +} + +TEST_F(AgentTest, ChainedToolCalls) { + // First response: call tool A + ToolCall call1("call_1", "tool_a", JsonValue::object()); + provider_->queueToolCalls({call1}); + + // Second response: call tool B + ToolCall call2("call_2", "tool_b", JsonValue::object()); + provider_->queueToolCalls({call2}); + + // Third response: final answer + provider_->queueResponse("Done with chained calls."); + + registry_->addSyncTool("tool_a", "Tool A", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("A result")); + }); + + registry_->addSyncTool("tool_b", "Tool B", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("B result")); + }); + + auto agent = ReActAgent::create(provider_, registry_); + auto result = runAgent(agent, "Run chained tools"); + + EXPECT_TRUE(result.isSuccess()); + EXPECT_EQ(result.iterationCount(), 3); +} + +TEST_F(AgentTest, ToolNotFound) { + // Call a tool that doesn't exist + ToolCall call1("call_1", "nonexistent_tool", JsonValue::object()); + provider_->queueToolCalls({call1}); + provider_->queueResponse("Tool error handled."); + + auto agent = ReActAgent::create(provider_, registry_); + auto result = runAgent(agent, "Call missing tool"); + + // Agent should still complete (tool error is passed to LLM) + EXPECT_TRUE(result.isSuccess()); + + // Check that tool result message contains error + bool found_error_message = false; + for (const auto& msg : result.messages) { + if (msg.role == Role::TOOL && + msg.content.find("not found") != std::string::npos) { + found_error_message = true; + break; + } + } + EXPECT_TRUE(found_error_message); +} + +TEST_F(AgentTest, ToolExecutionError) { + ToolCall call1("call_1", "failing_tool", JsonValue::object()); + provider_->queueToolCalls({call1}); + provider_->queueResponse("Handled the tool error."); + + registry_->addSyncTool( + "failing_tool", "Tool that fails", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(Error(-1, "Tool execution failed")); + }); + + auto agent = ReActAgent::create(provider_, registry_); + auto result = runAgent(agent, "Call failing tool"); + + EXPECT_TRUE(result.isSuccess()); + + // Check that error was recorded + if (!result.steps.empty() && !result.steps[0].tool_executions.empty()) { + EXPECT_FALSE(result.steps[0].tool_executions[0].success); + } +} + +// ============================================================================= +// Max Iterations and Timeout Tests +// ============================================================================= + +TEST_F(AgentTest, MaxIterationsReached) { + // Always return tool calls (will never complete naturally) + ToolCall call("call_1", "loop_tool", JsonValue::object()); + provider_->setDefaultToolCalls({call}); + + registry_->addSyncTool("loop_tool", "Loop forever", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("looping")); + }); + + AgentConfig config("test-model"); + config.withMaxIterations(3); + + auto agent = ReActAgent::create(provider_, registry_, config); + auto result = runAgentResult(agent, "Loop forever"); + + EXPECT_TRUE(mcp::holds_alternative(result)); + auto error = mcp::get(result); + EXPECT_EQ(error.code, AgentError::MAX_ITERATIONS); +} + +// ============================================================================= +// Callback Tests +// ============================================================================= + +TEST_F(AgentTest, StepCallback) { + provider_->queueResponse("Step 1"); + provider_->queueResponse("Step 2"); + + // First call returns tool, second returns final response + ToolCall call1("call_1", "test_tool", JsonValue::object()); + provider_->reset(); // Clear queue + provider_->queueToolCalls({call1}); + provider_->queueResponse("Final answer"); + + registry_->addSyncTool("test_tool", "Test", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("result")); + }); + + std::vector step_numbers; + + auto agent = ReActAgent::create(provider_, registry_); + agent->setStepCallback([&step_numbers](const AgentStep& step) { + step_numbers.push_back(step.step_number); + }); + + runAgent(agent, "Test with steps"); + + EXPECT_GE(step_numbers.size(), 1u); + if (!step_numbers.empty()) { + EXPECT_EQ(step_numbers[0], 1); + } +} + +TEST_F(AgentTest, ToolApprovalCallback) { + ToolCall call1("call_1", "approved_tool", JsonValue::object()); + ToolCall call2("call_2", "rejected_tool", JsonValue::object()); + provider_->queueToolCalls({call1, call2}); + + registry_->addSyncTool("approved_tool", "Approved", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("approved")); + }); + + registry_->addSyncTool("rejected_tool", "Rejected", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("rejected")); + }); + + auto agent = ReActAgent::create(provider_, registry_); + agent->setToolApprovalCallback([](const ToolCall& call) { + // Reject the "rejected_tool" + return call.name != "rejected_tool"; + }); + + auto result = runAgentResult(agent, "Call both tools"); + + // Agent should be cancelled due to rejected tool + EXPECT_TRUE(mcp::holds_alternative(result)); + auto error = mcp::get(result); + EXPECT_EQ(error.code, AgentError::CANCELLED); +} + +// ============================================================================= +// Context Tests +// ============================================================================= + +TEST_F(AgentTest, RunWithContext) { + provider_->setDefaultResponse("I remember the context."); + + std::vector context = {Message::user("My name is Alice"), + Message::assistant("Hello Alice!")}; + + auto agent = ReActAgent::create(provider_, registry_); + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + agent->run("What's my name?", context, d, std::move(cb)); + }); + + EXPECT_TRUE(result.isSuccess()); + + // Check that context was included + auto messages = provider_->lastMessages(); + ASSERT_GE(messages.size(), 3u); + EXPECT_EQ(messages[0].content, "My name is Alice"); + EXPECT_EQ(messages[1].content, "Hello Alice!"); + EXPECT_EQ(messages[2].content, "What's my name?"); +} + +// ============================================================================= +// State Tests +// ============================================================================= + +TEST_F(AgentTest, StateTracking) { + provider_->setDefaultResponse("Done"); + + auto agent = ReActAgent::create(provider_, registry_); + + EXPECT_EQ(agent->state().status, AgentStatus::IDLE); + EXPECT_FALSE(agent->isRunning()); + + runAgent(agent, "Test"); + + // After completion + EXPECT_EQ(agent->state().status, AgentStatus::COMPLETED); + EXPECT_FALSE(agent->isRunning()); + EXPECT_GE(agent->state().current_iteration, 1); +} + +TEST_F(AgentTest, UsageTracking) { + LLMResponse response; + response.message = Message::assistant("Response with usage"); + response.finish_reason = "stop"; + response.usage = Usage(100, 50); + + provider_->queueFullResponse(response); + + auto agent = ReActAgent::create(provider_, registry_); + auto result = runAgent(agent, "Test"); + + EXPECT_EQ(result.total_usage.prompt_tokens, 100); + EXPECT_EQ(result.total_usage.completion_tokens, 50); + EXPECT_EQ(result.total_usage.total_tokens, 150); +} + +// ============================================================================= +// Agent Types Tests +// ============================================================================= + +TEST(AgentTypesTest, AgentStatusToString) { + EXPECT_EQ(agentStatusToString(AgentStatus::IDLE), "idle"); + EXPECT_EQ(agentStatusToString(AgentStatus::RUNNING), "running"); + EXPECT_EQ(agentStatusToString(AgentStatus::COMPLETED), "completed"); + EXPECT_EQ(agentStatusToString(AgentStatus::FAILED), "failed"); + EXPECT_EQ(agentStatusToString(AgentStatus::CANCELLED), "cancelled"); + EXPECT_EQ(agentStatusToString(AgentStatus::MAX_ITERATIONS_REACHED), + "max_iterations_reached"); +} + +TEST(AgentTypesTest, AgentConfigBuilder) { + AgentConfig config("gpt-4"); + config.withSystemPrompt("System prompt") + .withMaxIterations(20) + .withTemperature(0.5) + .withMaxTokens(4000) + .withTimeout(std::chrono::milliseconds(60000)) + .withParallelToolCalls(false); + + EXPECT_EQ(config.llm_config.model, "gpt-4"); + EXPECT_EQ(config.system_prompt, "System prompt"); + EXPECT_EQ(config.max_iterations, 20); + EXPECT_TRUE(config.llm_config.temperature.has_value()); + EXPECT_DOUBLE_EQ(*config.llm_config.temperature, 0.5); + EXPECT_TRUE(config.llm_config.max_tokens.has_value()); + EXPECT_EQ(*config.llm_config.max_tokens, 4000); + EXPECT_EQ(config.timeout, std::chrono::milliseconds(60000)); + EXPECT_FALSE(config.parallel_tool_calls); +} + +TEST(AgentTypesTest, AgentState) { + AgentState state; + state.status = AgentStatus::RUNNING; + state.messages.push_back(Message::user("Hello")); + state.messages.push_back(Message::assistant("Hi!")); + + EXPECT_TRUE(state.isRunning()); + EXPECT_FALSE(state.isCompleted()); + EXPECT_EQ(state.lastContent(), "Hi!"); + + state.status = AgentStatus::COMPLETED; + EXPECT_FALSE(state.isRunning()); + EXPECT_TRUE(state.isCompleted()); +} + +TEST(AgentTypesTest, AgentResult) { + AgentResult result; + result.status = AgentStatus::COMPLETED; + result.response = "Final answer"; + result.steps.push_back(AgentStep()); + result.steps.push_back(AgentStep()); + result.total_usage = Usage(500, 200); + result.duration = std::chrono::milliseconds(1500); + + EXPECT_TRUE(result.isSuccess()); + EXPECT_EQ(result.iterationCount(), 2); + EXPECT_EQ(result.total_usage.total_tokens, 700); + EXPECT_EQ(result.duration.count(), 1500); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/callback_manager_test.cc b/third_party/gopher-orch/tests/gopher/orch/callback_manager_test.cc new file mode 100644 index 00000000..c026cd09 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/callback_manager_test.cc @@ -0,0 +1,499 @@ +// Unit tests for CallbackManager and CallbackHandler + +#include "orch_test_fixture.h" + +using namespace gopher::orch::callback; + +// ============================================================================= +// Test Helper: Recording callback handler +// ============================================================================= + +class RecordingHandler : public CallbackHandler { + public: + struct ChainEvent { + std::string type; // "start", "end", "error" + std::string name; + core::JsonValue data; + }; + + struct ToolEvent { + std::string type; + std::string tool_name; + core::JsonValue data; + }; + + std::vector chain_events; + std::vector tool_events; + std::vector> custom_events; + std::mutex mutex; + + void onChainStart(const RunInfo& info, + const core::JsonValue& input) override { + std::lock_guard lock(mutex); + chain_events.push_back({"start", info.name, input}); + } + + void onChainEnd(const RunInfo& info, const core::JsonValue& output) override { + std::lock_guard lock(mutex); + chain_events.push_back({"end", info.name, output}); + } + + void onChainError(const RunInfo& info, const core::Error& error) override { + std::lock_guard lock(mutex); + core::JsonValue data = core::JsonValue::object(); + data["code"] = error.code; + data["message"] = error.message; + chain_events.push_back({"error", info.name, data}); + } + + void onToolStart(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& input) override { + std::lock_guard lock(mutex); + (void)info; + tool_events.push_back({"start", tool_name, input}); + } + + void onToolEnd(const RunInfo& info, + const std::string& tool_name, + const core::JsonValue& output) override { + std::lock_guard lock(mutex); + (void)info; + tool_events.push_back({"end", tool_name, output}); + } + + void onToolError(const RunInfo& info, + const std::string& tool_name, + const core::Error& error) override { + std::lock_guard lock(mutex); + (void)info; + core::JsonValue data = core::JsonValue::object(); + data["code"] = error.code; + data["message"] = error.message; + tool_events.push_back({"error", tool_name, data}); + } + + void onCustomEvent(const std::string& event_name, + const core::JsonValue& data) override { + std::lock_guard lock(mutex); + custom_events.push_back({event_name, data}); + } +}; + +// ============================================================================= +// CallbackHandler Tests +// ============================================================================= + +TEST_F(OrchTest, CallbackHandlerDefaultMethods) { + // Default handler should not crash when methods are called + CallbackHandler handler; + RunInfo info; + info.name = "test"; + core::JsonValue data = core::JsonValue::object(); + core::Error error(1, "test error"); + + // These should all be no-ops + handler.onChainStart(info, data); + handler.onChainEnd(info, data); + handler.onChainError(info, error); + handler.onToolStart(info, "tool", data); + handler.onToolEnd(info, "tool", data); + handler.onToolError(info, "tool", error); + handler.onCustomEvent("event", data); + handler.onRetry(info, error, 1, 3); +} + +TEST_F(OrchTest, NoOpCallbackHandler) { + NoOpCallbackHandler handler; + RunInfo info; + core::JsonValue data = core::JsonValue::object(); + core::Error error(1, "test error"); + + // Should compile and run without issues + handler.onChainStart(info, data); + handler.onChainEnd(info, data); + handler.onChainError(info, error); +} + +// ============================================================================= +// CallbackManager Tests +// ============================================================================= + +TEST_F(OrchTest, CallbackManagerBasic) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + EXPECT_EQ(manager->handlerCount(), 1u); + + // Emit chain events + core::JsonValue input = core::JsonValue::object(); + input["key"] = "value"; + + auto run_info = manager->startChain("test_chain", input); + EXPECT_FALSE(run_info.run_id.empty()); + EXPECT_EQ(run_info.name, "test_chain"); + EXPECT_EQ(run_info.run_type, "chain"); + + core::JsonValue output = core::JsonValue::object(); + output["result"] = "success"; + manager->endChain(run_info, output); + + // Verify events were recorded + EXPECT_EQ(handler->chain_events.size(), 2u); + EXPECT_EQ(handler->chain_events[0].type, "start"); + EXPECT_EQ(handler->chain_events[0].name, "test_chain"); + EXPECT_EQ(handler->chain_events[1].type, "end"); + EXPECT_EQ(handler->chain_events[1].name, "test_chain"); +} + +TEST_F(OrchTest, CallbackManagerChainError) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + core::JsonValue input = core::JsonValue::object(); + auto run_info = manager->startChain("failing_chain", input); + + core::Error error(OrchError::INTERNAL_ERROR, "Something went wrong"); + manager->errorChain(run_info, error); + + EXPECT_EQ(handler->chain_events.size(), 2u); + EXPECT_EQ(handler->chain_events[0].type, "start"); + EXPECT_EQ(handler->chain_events[1].type, "error"); + EXPECT_EQ(handler->chain_events[1].data["code"].getInt(), + OrchError::INTERNAL_ERROR); +} + +TEST_F(OrchTest, CallbackManagerToolEvents) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + core::JsonValue input = core::JsonValue::object(); + input["arg"] = "test"; + + auto run_info = manager->startTool("my_tool", input); + EXPECT_EQ(run_info.run_type, "tool"); + + core::JsonValue output = core::JsonValue::object(); + output["result"] = 42; + manager->endTool(run_info, "my_tool", output); + + EXPECT_EQ(handler->tool_events.size(), 2u); + EXPECT_EQ(handler->tool_events[0].type, "start"); + EXPECT_EQ(handler->tool_events[0].tool_name, "my_tool"); + EXPECT_EQ(handler->tool_events[1].type, "end"); + EXPECT_EQ(handler->tool_events[1].tool_name, "my_tool"); +} + +TEST_F(OrchTest, CallbackManagerCustomEvents) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + core::JsonValue data = core::JsonValue::object(); + data["fsm"] = "connection"; + data["from"] = "disconnected"; + data["to"] = "connecting"; + + manager->emitCustomEvent("fsm.transition", data); + + EXPECT_EQ(handler->custom_events.size(), 1u); + EXPECT_EQ(handler->custom_events[0].first, "fsm.transition"); + EXPECT_EQ(handler->custom_events[0].second["fsm"].getString(), "connection"); +} + +TEST_F(OrchTest, CallbackManagerMultipleHandlers) { + auto manager = std::make_shared(); + auto handler1 = std::make_shared(); + auto handler2 = std::make_shared(); + + manager->addHandler(handler1); + manager->addHandler(handler2); + EXPECT_EQ(manager->handlerCount(), 2u); + + core::JsonValue input = core::JsonValue::object(); + auto run_info = manager->startChain("multi_handler_chain", input); + manager->endChain(run_info, input); + + // Both handlers should have received the events + EXPECT_EQ(handler1->chain_events.size(), 2u); + EXPECT_EQ(handler2->chain_events.size(), 2u); +} + +TEST_F(OrchTest, CallbackManagerRemoveHandler) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + EXPECT_EQ(manager->handlerCount(), 1u); + + manager->removeHandler(handler); + EXPECT_EQ(manager->handlerCount(), 0u); + + // Events should not be received after removal + core::JsonValue input = core::JsonValue::object(); + auto run_info = manager->startChain("after_removal", input); + + EXPECT_EQ(handler->chain_events.size(), 0u); +} + +TEST_F(OrchTest, CallbackManagerClearHandlers) { + auto manager = std::make_shared(); + auto handler1 = std::make_shared(); + auto handler2 = std::make_shared(); + + manager->addHandler(handler1); + manager->addHandler(handler2); + EXPECT_EQ(manager->handlerCount(), 2u); + + manager->clearHandlers(); + EXPECT_EQ(manager->handlerCount(), 0u); +} + +TEST_F(OrchTest, CallbackManagerChildManager) { + auto parent = std::make_shared(); + auto handler = std::make_shared(); + + parent->addHandler(handler); + + // Create child manager + auto child = parent->child(); + + // Child should inherit handlers + EXPECT_EQ(child->handlerCount(), 1u); + + // Child should have parent_run_id set + EXPECT_EQ(child->parentRunId(), parent->runId()); + + // Events from child should be received + core::JsonValue input = core::JsonValue::object(); + auto run_info = child->startChain("child_chain", input); + + EXPECT_EQ(handler->chain_events.size(), 1u); + EXPECT_EQ(run_info.parent_run_id, parent->runId()); +} + +TEST_F(OrchTest, CallbackManagerTags) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + manager->addTags({"env:prod", "version:1.0"}); + + core::JsonValue input = core::JsonValue::object(); + auto run_info = manager->startChain("tagged_chain", input, {"extra:tag"}); + + // Should have both inheritable and provided tags + EXPECT_EQ(run_info.tags.size(), 3u); +} + +TEST_F(OrchTest, CallbackManagerMetadata) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + core::JsonValue user_id = core::JsonValue("user123"); + manager->addMetadata("user_id", user_id); + + core::JsonValue input = core::JsonValue::object(); + core::JsonValue extra_metadata = core::JsonValue::object(); + extra_metadata["request_id"] = "req456"; + + auto run_info = + manager->startChain("metadata_chain", input, {}, extra_metadata); + + // Should have merged metadata + EXPECT_EQ(run_info.metadata["user_id"].getString(), "user123"); + EXPECT_EQ(run_info.metadata["request_id"].getString(), "req456"); +} + +// ============================================================================= +// ChainGuard Tests +// ============================================================================= + +TEST_F(OrchTest, ChainGuardSuccess) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + { + core::JsonValue input = core::JsonValue::object(); + ChainGuard guard(manager, "guarded_chain", input); + + // Simulate work... + core::JsonValue output = core::JsonValue::object(); + output["status"] = "done"; + guard.setOutput(output); + } + + EXPECT_EQ(handler->chain_events.size(), 2u); + EXPECT_EQ(handler->chain_events[0].type, "start"); + EXPECT_EQ(handler->chain_events[1].type, "end"); +} + +TEST_F(OrchTest, ChainGuardError) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + { + core::JsonValue input = core::JsonValue::object(); + ChainGuard guard(manager, "failing_guarded_chain", input); + + core::Error error(OrchError::INTERNAL_ERROR, "Failed"); + guard.setError(error); + } + + EXPECT_EQ(handler->chain_events.size(), 2u); + EXPECT_EQ(handler->chain_events[0].type, "start"); + EXPECT_EQ(handler->chain_events[1].type, "error"); +} + +TEST_F(OrchTest, ChainGuardAutoError) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + { + core::JsonValue input = core::JsonValue::object(); + ChainGuard guard(manager, "unfinished_chain", input); + // Guard goes out of scope without setOutput/setError + } + + // Should automatically emit error + EXPECT_EQ(handler->chain_events.size(), 2u); + EXPECT_EQ(handler->chain_events[0].type, "start"); + EXPECT_EQ(handler->chain_events[1].type, "error"); +} + +// ============================================================================= +// ToolGuard Tests +// ============================================================================= + +TEST_F(OrchTest, ToolGuardSuccess) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + { + core::JsonValue input = core::JsonValue::object(); + ToolGuard guard(manager, "guarded_tool", input); + + core::JsonValue output = core::JsonValue::object(); + output["result"] = 42; + guard.setOutput(output); + } + + EXPECT_EQ(handler->tool_events.size(), 2u); + EXPECT_EQ(handler->tool_events[0].type, "start"); + EXPECT_EQ(handler->tool_events[1].type, "end"); +} + +TEST_F(OrchTest, ToolGuardAutoError) { + auto manager = std::make_shared(); + auto handler = std::make_shared(); + + manager->addHandler(handler); + + { + core::JsonValue input = core::JsonValue::object(); + ToolGuard guard(manager, "unfinished_tool", input); + // Guard goes out of scope without completion + } + + EXPECT_EQ(handler->tool_events.size(), 2u); + EXPECT_EQ(handler->tool_events[0].type, "start"); + EXPECT_EQ(handler->tool_events[1].type, "error"); +} + +// ============================================================================= +// RunInfo Tests +// ============================================================================= + +TEST_F(OrchTest, RunInfoDuration) { + RunInfo info; + info.start_time = std::chrono::steady_clock::now(); + + // Sleep a bit + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + auto duration = info.durationMs(); + EXPECT_GE(duration.count(), 10); +} + +// ============================================================================= +// LoggingCallbackHandler Tests +// ============================================================================= + +TEST_F(OrchTest, LoggingCallbackHandlerBasic) { + // Just verify it doesn't crash + LoggingCallbackHandler handler(LoggingCallbackHandler::LogLevel::DEBUG); + + RunInfo info; + info.name = "test"; + info.start_time = std::chrono::steady_clock::now(); + + core::JsonValue data = core::JsonValue::object(); + data["key"] = "value"; + + handler.onChainStart(info, data); + handler.onChainEnd(info, data); + handler.onChainError(info, core::Error(1, "test error")); + handler.onToolStart(info, "tool", data); + handler.onToolEnd(info, "tool", data); + handler.onToolError(info, "tool", core::Error(1, "test error")); + handler.onCustomEvent("custom", data); + handler.onRetry(info, core::Error(1, "retry error"), 1, 3); +} + +// ============================================================================= +// RunnableConfig Callbacks Integration Tests +// ============================================================================= + +TEST_F(OrchTest, RunnableConfigWithCallbacks) { + auto manager = std::make_shared(); + + RunnableConfig config; + config.withCallbacks(manager); + + EXPECT_TRUE(config.hasCallbacks()); + EXPECT_EQ(config.callbacks(), manager); +} + +TEST_F(OrchTest, RunnableConfigCallbacksInheritance) { + auto manager = std::make_shared(); + + RunnableConfig parent; + parent.withCallbacks(manager); + + RunnableConfig child = parent.child(); + + // Child should inherit callbacks + EXPECT_TRUE(child.hasCallbacks()); + EXPECT_EQ(child.callbacks(), manager); +} + +TEST_F(OrchTest, RunnableConfigMergeCallbacks) { + auto manager1 = std::make_shared(); + auto manager2 = std::make_shared(); + + RunnableConfig config1; + config1.withCallbacks(manager1); + + RunnableConfig config2; + config2.withCallbacks(manager2); + + config1.merge(config2); + + // Merged callbacks should be from config2 + EXPECT_EQ(config1.callbacks(), manager2); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/circuit_breaker_test.cc b/third_party/gopher-orch/tests/gopher/orch/circuit_breaker_test.cc new file mode 100644 index 00000000..40117486 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/circuit_breaker_test.cc @@ -0,0 +1,96 @@ +// Unit tests for CircuitBreaker resilience pattern + +#include "orch_test_fixture.h" + +// ============================================================================= +// CircuitBreaker Tests +// ============================================================================= + +TEST_F(OrchTest, CircuitBreakerClosed) { + // Normal operation - circuit stays closed + auto successLambda = makeJsonLambda( + [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["ok"] = JsonValue(true); + return makeSuccess(JsonValue(result)); + }, + "SuccessLambda"); + + auto cb = withCircuitBreaker(successLambda); + + EXPECT_EQ(cb->state(), CircuitState::CLOSED); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb_fn) { + cb->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb_fn)); + }); + + EXPECT_TRUE(result["ok"].getBool()); + EXPECT_EQ(cb->state(), CircuitState::CLOSED); +} + +TEST_F(OrchTest, CircuitBreakerOpens) { + // Circuit opens after threshold failures + std::atomic call_count{0}; + + auto failingLambda = makeJsonLambda( + [&call_count](const JsonValue&) -> Result { + call_count++; + return Result(Error(OrchError::INTERNAL_ERROR, "Failed")); + }, + "FailingLambda"); + + CircuitBreakerPolicy policy; + policy.failure_threshold = 3; + policy.recovery_timeout_ms = 60000; // Long timeout for test + auto cb = withCircuitBreaker(failingLambda, policy); + + EXPECT_EQ(cb->state(), CircuitState::CLOSED); + + // Cause failures to open circuit + for (int i = 0; i < 3; i++) { + auto result = runToCompletionResult([&](Dispatcher& d, + JsonCallback cb_fn) { + cb->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb_fn)); + }); + EXPECT_TRUE(mcp::holds_alternative(result)); + } + + EXPECT_EQ(cb->state(), CircuitState::OPEN); + EXPECT_EQ(call_count.load(), 3); + + // Next call should fail fast without calling inner + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb_fn) { + cb->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb_fn)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::CIRCUIT_OPEN); + EXPECT_EQ(call_count.load(), 3); // Inner not called +} + +TEST_F(OrchTest, CircuitBreakerReset) { + // Manual reset works + auto failingLambda = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result(Error(OrchError::INTERNAL_ERROR, "Failed")); + }, + "FailingLambda"); + + CircuitBreakerPolicy policy; + policy.failure_threshold = 1; // Open after 1 failure + auto cb = withCircuitBreaker(failingLambda, policy); + + // Cause failure to open circuit + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb_fn) { + cb->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb_fn)); + }); + + EXPECT_EQ(cb->state(), CircuitState::OPEN); + + // Reset should close circuit + cb->reset(); + EXPECT_EQ(cb->state(), CircuitState::CLOSED); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/fallback_test.cc b/third_party/gopher-orch/tests/gopher/orch/fallback_test.cc new file mode 100644 index 00000000..9f78bf31 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/fallback_test.cc @@ -0,0 +1,102 @@ +// Unit tests for Fallback resilience pattern + +#include "orch_test_fixture.h" + +// ============================================================================= +// Fallback Tests +// ============================================================================= + +TEST_F(OrchTest, FallbackPrimarySuccess) { + // Primary succeeds, fallback not used + std::atomic fallback_called{0}; + + auto primary = makeJsonLambda( + [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["source"] = JsonValue("primary"); + return makeSuccess(JsonValue(result)); + }, + "Primary"); + + auto fallback = makeJsonLambda( + [&fallback_called](const JsonValue&) -> Result { + fallback_called++; + JsonValue result = JsonValue::object(); + result["source"] = JsonValue("fallback"); + return makeSuccess(JsonValue(result)); + }, + "Fallback"); + + auto fallbackLambda = withFallback(primary).orElse(fallback).build(); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + fallbackLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_EQ(result["source"].getString(), "primary"); + EXPECT_EQ(fallback_called.load(), 0); +} + +TEST_F(OrchTest, FallbackUsed) { + // Primary fails, fallback used + auto primary = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result( + Error(OrchError::INTERNAL_ERROR, "Primary failed")); + }, + "Primary"); + + auto fallback1 = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result( + Error(OrchError::INTERNAL_ERROR, "Fallback1 failed")); + }, + "Fallback1"); + + auto fallback2 = makeJsonLambda( + [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["source"] = JsonValue("fallback2"); + return makeSuccess(JsonValue(result)); + }, + "Fallback2"); + + auto fallbackLambda = + withFallback(primary).orElse(fallback1).orElse(fallback2).build(); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + fallbackLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_EQ(result["source"].getString(), "fallback2"); +} + +TEST_F(OrchTest, FallbackExhausted) { + // All fallbacks fail + auto primary = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result(Error(OrchError::INTERNAL_ERROR, "Failed")); + }, + "Primary"); + + auto fallback = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result(Error(OrchError::INTERNAL_ERROR, "Failed")); + }, + "Fallback"); + + auto fallbackLambda = withFallback(primary).orElse(fallback).build(); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + fallbackLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::FALLBACK_EXHAUSTED); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/human_approval_test.cc b/third_party/gopher-orch/tests/gopher/orch/human_approval_test.cc new file mode 100644 index 00000000..04e06625 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/human_approval_test.cc @@ -0,0 +1,434 @@ +// Unit tests for HumanApproval and ApprovalHandler + +#include "orch_test_fixture.h" + +using namespace gopher::orch::human; + +// ============================================================================= +// ApprovalResponse Tests +// ============================================================================= + +TEST_F(OrchTest, ApprovalResponseApprove) { + auto response = ApprovalResponse::approve("User approved"); + + EXPECT_TRUE(response.approved); + EXPECT_EQ(response.reason, "User approved"); + EXPECT_TRUE(response.modifications.isNull()); +} + +TEST_F(OrchTest, ApprovalResponseDeny) { + auto response = ApprovalResponse::deny("User rejected"); + + EXPECT_FALSE(response.approved); + EXPECT_EQ(response.reason, "User rejected"); +} + +TEST_F(OrchTest, ApprovalResponseApproveWithModifications) { + core::JsonValue mods = core::JsonValue::object(); + mods["amount"] = 100; + + auto response = + ApprovalResponse::approveWithModifications(mods, "Reduced amount"); + + EXPECT_TRUE(response.approved); + EXPECT_EQ(response.reason, "Reduced amount"); + EXPECT_FALSE(response.modifications.isNull()); + EXPECT_EQ(response.modifications["amount"].getInt(), 100); +} + +// ============================================================================= +// AutoApprovalHandler Tests +// ============================================================================= + +TEST_F(OrchTest, AutoApprovalHandlerApproves) { + auto handler = std::make_shared("Test auto-approve"); + + ApprovalRequest request; + request.action_name = "dangerous_action"; + request.prompt = "Are you sure?"; + + bool callback_called = false; + ApprovalResponse received_response; + + handler->requestApproval(request, [&](ApprovalResponse response) { + callback_called = true; + received_response = std::move(response); + }); + + EXPECT_TRUE(callback_called); + EXPECT_TRUE(received_response.approved); + EXPECT_EQ(received_response.reason, "Test auto-approve"); +} + +// ============================================================================= +// AutoDenyHandler Tests +// ============================================================================= + +TEST_F(OrchTest, AutoDenyHandlerDenies) { + auto handler = std::make_shared("Security policy"); + + ApprovalRequest request; + request.action_name = "blocked_action"; + + bool callback_called = false; + ApprovalResponse received_response; + + handler->requestApproval(request, [&](ApprovalResponse response) { + callback_called = true; + received_response = std::move(response); + }); + + EXPECT_TRUE(callback_called); + EXPECT_FALSE(received_response.approved); + EXPECT_EQ(received_response.reason, "Security policy"); +} + +// ============================================================================= +// CallbackApprovalHandler Tests +// ============================================================================= + +TEST_F(OrchTest, CallbackApprovalHandlerCustomLogic) { + // Approve only if amount is less than 1000 + auto handler = std::make_shared( + [](const ApprovalRequest& req) -> ApprovalResponse { + if (req.preview.contains("amount")) { + int amount = req.preview["amount"].getInt(); + if (amount < 1000) { + return ApprovalResponse::approve("Amount within limit"); + } else { + return ApprovalResponse::deny("Amount exceeds limit"); + } + } + return ApprovalResponse::approve("No amount specified"); + }); + + // Test with low amount - should approve + ApprovalRequest request1; + request1.preview = core::JsonValue::object(); + request1.preview["amount"] = 500; + + ApprovalResponse response1; + handler->requestApproval( + request1, [&response1](ApprovalResponse r) { response1 = std::move(r); }); + + EXPECT_TRUE(response1.approved); + + // Test with high amount - should deny + ApprovalRequest request2; + request2.preview = core::JsonValue::object(); + request2.preview["amount"] = 2000; + + ApprovalResponse response2; + handler->requestApproval( + request2, [&response2](ApprovalResponse r) { response2 = std::move(r); }); + + EXPECT_FALSE(response2.approved); +} + +// ============================================================================= +// ConditionalApprovalHandler Tests +// ============================================================================= + +TEST_F(OrchTest, ConditionalApprovalHandlerBasic) { + // Approve if action starts with "safe_" + auto handler = std::make_shared( + [](const ApprovalRequest& req) { + return req.action_name.find("safe_") == 0; + }, + "Safe operation", "Unsafe operation blocked"); + + // Test safe action + ApprovalRequest safe_request; + safe_request.action_name = "safe_operation"; + + ApprovalResponse safe_response; + handler->requestApproval(safe_request, [&safe_response](ApprovalResponse r) { + safe_response = std::move(r); + }); + + EXPECT_TRUE(safe_response.approved); + EXPECT_EQ(safe_response.reason, "Safe operation"); + + // Test unsafe action + ApprovalRequest unsafe_request; + unsafe_request.action_name = "dangerous_operation"; + + ApprovalResponse unsafe_response; + handler->requestApproval(unsafe_request, + [&unsafe_response](ApprovalResponse r) { + unsafe_response = std::move(r); + }); + + EXPECT_FALSE(unsafe_response.approved); + EXPECT_EQ(unsafe_response.reason, "Unsafe operation blocked"); +} + +// ============================================================================= +// AsyncCallbackApprovalHandler Tests +// ============================================================================= + +TEST_F(OrchTest, AsyncCallbackApprovalHandlerBasic) { + auto handler = std::make_shared( + [](const ApprovalRequest& req, + std::function callback) { + // Simulate async approval (in real code, this might post to a queue) + callback( + ApprovalResponse::approve("Async approved: " + req.action_name)); + }); + + ApprovalRequest request; + request.action_name = "async_action"; + + ApprovalResponse response; + handler->requestApproval( + request, [&response](ApprovalResponse r) { response = std::move(r); }); + + EXPECT_TRUE(response.approved); + EXPECT_EQ(response.reason, "Async approved: async_action"); +} + +// ============================================================================= +// RecordingApprovalHandler Tests +// ============================================================================= + +TEST_F(OrchTest, RecordingApprovalHandlerRecords) { + auto inner = std::make_shared(); + auto handler = std::make_shared(inner); + + // Make several requests + ApprovalRequest request1; + request1.action_name = "action1"; + handler->requestApproval(request1, [](ApprovalResponse) {}); + + ApprovalRequest request2; + request2.action_name = "action2"; + handler->requestApproval(request2, [](ApprovalResponse) {}); + + ApprovalRequest request3; + request3.action_name = "action3"; + handler->requestApproval(request3, [](ApprovalResponse) {}); + + // Verify recordings + EXPECT_EQ(handler->requestCount(), 3u); + + auto recorded = handler->recordedRequests(); + EXPECT_EQ(recorded[0].action_name, "action1"); + EXPECT_EQ(recorded[1].action_name, "action2"); + EXPECT_EQ(recorded[2].action_name, "action3"); + + // Clear and verify + handler->clearRecords(); + EXPECT_EQ(handler->requestCount(), 0u); +} + +// ============================================================================= +// HumanApproval Runnable Tests +// ============================================================================= + +// Simple test runnable that doubles a number +class DoublerRunnable + : public core::Runnable { + public: + std::string name() const override { return "Doubler"; } + + void invoke(const core::JsonValue& input, + const core::RunnableConfig& config, + core::Dispatcher& dispatcher, + core::ResultCallback callback) override { + (void)config; + dispatcher.post([input, callback]() { + core::JsonValue output = core::JsonValue::object(); + if (input.contains("value")) { + output["result"] = input["value"].getInt() * 2; + } else { + output["result"] = 0; + } + callback(core::makeSuccess(std::move(output))); + }); + } +}; + +TEST_F(OrchTest, HumanApprovalApproved) { + auto inner = std::make_shared(); + auto handler = std::make_shared("User approved"); + + auto approval = HumanApproval::create( + inner, handler, "Double this value?"); + + EXPECT_EQ(approval->name(), "HumanApproval(Doubler)"); + + core::JsonValue input = core::JsonValue::object(); + input["value"] = 21; + + auto result = runToCompletion( + [&](core::Dispatcher& dispatcher, + core::ResultCallback callback) { + approval->invoke(input, core::RunnableConfig(), dispatcher, + std::move(callback)); + }); + + EXPECT_EQ(result["result"].getInt(), 42); +} + +TEST_F(OrchTest, HumanApprovalDenied) { + auto inner = std::make_shared(); + auto handler = std::make_shared("Not authorized"); + + auto approval = HumanApproval::create( + inner, handler, "Double this value?"); + + core::JsonValue input = core::JsonValue::object(); + input["value"] = 21; + + auto result = runToCompletionResult( + [&](core::Dispatcher& dispatcher, + core::ResultCallback callback) { + approval->invoke(input, core::RunnableConfig(), dispatcher, + std::move(callback)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + auto error = mcp::get(result); + EXPECT_EQ(error.code, OrchError::APPROVAL_DENIED); + EXPECT_EQ(error.message, "Not authorized"); +} + +TEST_F(OrchTest, HumanApprovalWithModifications) { + auto inner = std::make_shared(); + + // Handler that modifies the input + auto handler = std::make_shared( + [](const ApprovalRequest& req) -> ApprovalResponse { + (void)req; + // Modify value to 50 instead of original + core::JsonValue mods = core::JsonValue::object(); + mods["value"] = 50; + return ApprovalResponse::approveWithModifications(mods, + "Value adjusted"); + }); + + auto approval = HumanApproval::create( + inner, handler, "Double this value?"); + + core::JsonValue input = core::JsonValue::object(); + input["value"] = 21; // Original value + + auto result = runToCompletion( + [&](core::Dispatcher& dispatcher, + core::ResultCallback callback) { + approval->invoke(input, core::RunnableConfig(), dispatcher, + std::move(callback)); + }); + + // Should be 50 * 2 = 100, not 21 * 2 = 42 + EXPECT_EQ(result["result"].getInt(), 100); +} + +TEST_F(OrchTest, HumanApprovalRequestContainsPreview) { + auto inner = std::make_shared(); + auto recording_handler = std::make_shared( + std::make_shared()); + + auto approval = HumanApproval::create( + inner, recording_handler, "Please approve this operation"); + + core::JsonValue input = core::JsonValue::object(); + input["value"] = 42; + input["description"] = "Test operation"; + + runToCompletion( + [&](core::Dispatcher& dispatcher, + core::ResultCallback callback) { + approval->invoke(input, core::RunnableConfig(), dispatcher, + std::move(callback)); + }); + + // Verify the request was properly formed + EXPECT_EQ(recording_handler->requestCount(), 1u); + auto recorded = recording_handler->recordedRequests(); + EXPECT_EQ(recorded[0].action_name, "Doubler"); + EXPECT_EQ(recorded[0].prompt, "Please approve this operation"); + EXPECT_EQ(recorded[0].preview["value"].getInt(), 42); + EXPECT_EQ(recorded[0].preview["description"].getString(), "Test operation"); +} + +// ============================================================================= +// JsonHumanApproval Alias Test +// ============================================================================= + +TEST_F(OrchTest, JsonHumanApprovalAlias) { + auto inner = std::make_shared(); + auto handler = std::make_shared(); + + // JsonHumanApproval is alias for HumanApproval + auto approval = JsonHumanApproval::create(inner, handler, "Approve?"); + + core::JsonValue input = core::JsonValue::object(); + input["value"] = 10; + + auto result = runToCompletion( + [&](core::Dispatcher& dispatcher, + core::ResultCallback callback) { + approval->invoke(input, core::RunnableConfig(), dispatcher, + std::move(callback)); + }); + + EXPECT_EQ(result["result"].getInt(), 20); +} + +// ============================================================================= +// Integration: HumanApproval with Callback Manager +// ============================================================================= + +TEST_F(OrchTest, HumanApprovalWithCallbackManager) { + auto inner = std::make_shared(); + auto handler = std::make_shared(); + + auto approval = HumanApproval::create( + inner, handler, "Approve?"); + + // Create callback manager to track execution + auto manager = std::make_shared(); + + // Use a recording handler to verify events + class RecordingCallback : public callback::CallbackHandler { + public: + std::vector events; + + void onChainStart(const callback::RunInfo& info, + const core::JsonValue&) override { + events.push_back("start:" + info.name); + } + + void onChainEnd(const callback::RunInfo& info, + const core::JsonValue&) override { + events.push_back("end:" + info.name); + } + }; + + auto recorder = std::make_shared(); + manager->addHandler(recorder); + + core::RunnableConfig config; + config.withCallbacks(manager); + + // Start a chain that wraps the approval + auto run_info = + manager->startChain("approval_test", core::JsonValue::object()); + + core::JsonValue input = core::JsonValue::object(); + input["value"] = 5; + + auto result = runToCompletion( + [&](core::Dispatcher& dispatcher, + core::ResultCallback callback) { + approval->invoke(input, config, dispatcher, std::move(callback)); + }); + + manager->endChain(run_info, result); + + EXPECT_EQ(result["result"].getInt(), 10); + EXPECT_EQ(recorder->events.size(), 2u); + EXPECT_EQ(recorder->events[0], "start:approval_test"); + EXPECT_EQ(recorder->events[1], "end:approval_test"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/integration_test.cc b/third_party/gopher-orch/tests/gopher/orch/integration_test.cc new file mode 100644 index 00000000..e33042e0 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/integration_test.cc @@ -0,0 +1,81 @@ +// Integration tests for gopher-orch framework +// Tests combining multiple components together + +#include "orch_test_fixture.h" + +// ============================================================================= +// Integration Tests +// ============================================================================= + +TEST_F(OrchTest, SequenceWithServer) { + // Create a workflow that uses server tools + auto server = makeMockServer("workflow-server"); + + server->addTool("fetch", "Fetch data") + .setHandler("fetch", [](const JsonValue& args) -> Result { + JsonValue result = JsonValue::object(); + result["data"] = JsonValue("fetched-" + args["id"].getString()); + return makeSuccess(JsonValue(result)); + }); + + server->addTool("process", "Process data") + .setHandler("process", [](const JsonValue& args) -> Result { + JsonValue result = JsonValue::object(); + result["processed"] = + JsonValue(args["data"].getString() + "-processed"); + return makeSuccess(JsonValue(result)); + }); + + server->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + // Build workflow: fetch -> process + auto workflow = sequence("FetchAndProcess") + .add(server->tool("fetch")) + .add(server->tool("process")) + .build(); + + JsonValue input = JsonValue::object(); + input["id"] = JsonValue("123"); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + workflow->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["processed"].getString(), "fetched-123-processed"); +} + +TEST_F(OrchTest, ParallelWithServerTools) { + auto server = makeMockServer("parallel-server"); + + server->addTool("tool_a").setHandler( + "tool_a", [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["from"] = JsonValue("tool_a"); + return makeSuccess(JsonValue(result)); + }); + + server->addTool("tool_b").setHandler( + "tool_b", [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["from"] = JsonValue("tool_b"); + return makeSuccess(JsonValue(result)); + }); + + server->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + auto workflow = parallel("ParallelTools") + .add("a", server->tool("tool_a")) + .add("b", server->tool("tool_b")) + .build(); + + JsonValue result = runToCompletion([&](Dispatcher& d, + JsonCallback cb) { + workflow->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["a"]["from"].getString(), "tool_a"); + EXPECT_EQ(result["b"]["from"].getString(), "tool_b"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/lambda_test.cc b/third_party/gopher-orch/tests/gopher/orch/lambda_test.cc new file mode 100644 index 00000000..dd4c2e21 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/lambda_test.cc @@ -0,0 +1,73 @@ +// Unit tests for Lambda runnable + +#include "orch_test_fixture.h" + +// ============================================================================= +// Lambda Tests +// ============================================================================= + +TEST_F(OrchTest, LambdaSyncBasic) { + // Create a simple lambda that doubles a number + auto doubler = makeJsonLambda( + [](const JsonValue& input) -> Result { + int value = input["value"].getInt(); + JsonValue result = JsonValue::object(); + result["result"] = JsonValue(value * 2); + return makeSuccess(JsonValue(result)); + }, + "Doubler"); + + EXPECT_EQ(doubler->name(), "Doubler"); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + JsonValue input = JsonValue::object(); + input["value"] = JsonValue(21); + doubler->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["result"].getInt(), 42); +} + +TEST_F(OrchTest, LambdaWithConfig) { + // Lambda that uses config + auto configReader = makeJsonLambda( + [](const JsonValue& input, + const RunnableConfig& config) -> Result { + JsonValue result = JsonValue::object(); + auto tag = config.tag("mode"); + result["mode"] = + JsonValue(tag.has_value() ? tag.value() : std::string("default")); + return makeSuccess(JsonValue(result)); + }, + "ConfigReader"); + + RunnableConfig config; + config.withTag("mode", "test"); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + configReader->invoke(JsonValue::object(), config, d, std::move(cb)); + }); + + EXPECT_EQ(result["mode"].getString(), "test"); +} + +TEST_F(OrchTest, LambdaError) { + auto errorLambda = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result( + Error(OrchError::INVALID_ARGUMENT, "Test error")); + }, + "ErrorLambda"); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + errorLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::INVALID_ARGUMENT); + EXPECT_EQ(mcp::get(result).message, "Test error"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/llm_provider_test.cc b/third_party/gopher-orch/tests/gopher/orch/llm_provider_test.cc new file mode 100644 index 00000000..57b515fb --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/llm_provider_test.cc @@ -0,0 +1,284 @@ +// Unit tests for LLM Providers (OpenAI, Anthropic) + +#include "gopher/orch/llm/anthropic_provider.h" +#include "gopher/orch/llm/openai_provider.h" +#include "mock_http_client.h" +#include "mock_llm_provider.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::llm; + +// ============================================================================= +// MockLLMProvider Tests +// ============================================================================= + +class MockLLMProviderTest : public OrchTest { + protected: + std::shared_ptr provider_; + + void SetUp() override { + OrchTest::SetUp(); + provider_ = makeMockLLMProvider("test-provider"); + } +}; + +TEST_F(MockLLMProviderTest, BasicConfiguration) { + EXPECT_EQ(provider_->name(), "test-provider"); + EXPECT_EQ(provider_->endpoint(), "mock://localhost/v1/chat"); + EXPECT_TRUE(provider_->isConfigured()); + EXPECT_TRUE(provider_->isModelSupported("any-model")); +} + +TEST_F(MockLLMProviderTest, DefaultResponse) { + provider_->setDefaultResponse("Hello from mock!"); + + std::vector messages = {Message::user("Hi")}; + LLMConfig config("test-model"); + + auto response = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, {}, config, d, std::move(cb)); + }); + + EXPECT_EQ(response.message.content, "Hello from mock!"); + EXPECT_EQ(response.finish_reason, "stop"); + EXPECT_EQ(provider_->callCount(), 1u); +} + +TEST_F(MockLLMProviderTest, QueuedResponses) { + provider_->queueResponse("First response"); + provider_->queueResponse("Second response"); + + std::vector messages = {Message::user("Hi")}; + LLMConfig config("test-model"); + + auto response1 = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, {}, config, d, std::move(cb)); + }); + EXPECT_EQ(response1.message.content, "First response"); + + auto response2 = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, {}, config, d, std::move(cb)); + }); + EXPECT_EQ(response2.message.content, "Second response"); + + EXPECT_EQ(provider_->callCount(), 2u); +} + +TEST_F(MockLLMProviderTest, ToolCallResponse) { + std::vector tool_calls; + tool_calls.push_back(ToolCall("call_123", "search", JsonValue::object())); + + provider_->queueToolCalls(tool_calls); + + std::vector messages = {Message::user("Search for something")}; + LLMConfig config("test-model"); + + auto response = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, {}, config, d, std::move(cb)); + }); + + EXPECT_TRUE(response.hasToolCalls()); + EXPECT_EQ(response.toolCalls().size(), 1u); + EXPECT_EQ(response.toolCalls()[0].name, "search"); + EXPECT_EQ(response.toolCalls()[0].id, "call_123"); + EXPECT_EQ(response.finish_reason, "tool_calls"); +} + +TEST_F(MockLLMProviderTest, ErrorResponse) { + provider_->queueError(LLMError::RATE_LIMITED, "Rate limit exceeded"); + + std::vector messages = {Message::user("Hi")}; + LLMConfig config("test-model"); + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, {}, config, d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, LLMError::RATE_LIMITED); + EXPECT_EQ(mcp::get(result).message, "Rate limit exceeded"); +} + +TEST_F(MockLLMProviderTest, RecordsLastCall) { + ToolSpec tool1("search", "Search the web", JsonValue::object()); + std::vector tools = {tool1}; + + std::vector messages = {Message::system("You are helpful"), + Message::user("Hello")}; + LLMConfig config("gpt-4"); + config.withTemperature(0.7); + + provider_->setDefaultResponse("OK"); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, tools, config, d, std::move(cb)); + }); + + EXPECT_EQ(provider_->lastMessages().size(), 2u); + EXPECT_EQ(provider_->lastMessages()[0].role, Role::SYSTEM); + EXPECT_EQ(provider_->lastMessages()[1].content, "Hello"); + + EXPECT_EQ(provider_->lastTools().size(), 1u); + EXPECT_EQ(provider_->lastTools()[0].name, "search"); + + EXPECT_EQ(provider_->lastConfig().model, "gpt-4"); + EXPECT_TRUE(provider_->lastConfig().temperature.has_value()); + EXPECT_DOUBLE_EQ(*provider_->lastConfig().temperature, 0.7); +} + +TEST_F(MockLLMProviderTest, Reset) { + provider_->queueResponse("Test"); + + std::vector messages = {Message::user("Hi")}; + LLMConfig config("test-model"); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, {}, config, d, std::move(cb)); + }); + + EXPECT_EQ(provider_->callCount(), 1u); + EXPECT_FALSE(provider_->lastMessages().empty()); + + provider_->reset(); + + EXPECT_EQ(provider_->callCount(), 0u); + EXPECT_TRUE(provider_->lastMessages().empty()); +} + +// ============================================================================= +// LLM Type Tests +// ============================================================================= + +TEST(LLMTypesTest, MessageFactoryMethods) { + auto system = Message::system("System prompt"); + EXPECT_EQ(system.role, Role::SYSTEM); + EXPECT_EQ(system.content, "System prompt"); + + auto user = Message::user("User input"); + EXPECT_EQ(user.role, Role::USER); + EXPECT_EQ(user.content, "User input"); + + auto assistant = Message::assistant("Response"); + EXPECT_EQ(assistant.role, Role::ASSISTANT); + EXPECT_EQ(assistant.content, "Response"); + + auto tool_result = Message::toolResult("call_123", "Tool output"); + EXPECT_EQ(tool_result.role, Role::TOOL); + EXPECT_EQ(tool_result.content, "Tool output"); + EXPECT_TRUE(tool_result.tool_call_id.has_value()); + EXPECT_EQ(*tool_result.tool_call_id, "call_123"); +} + +TEST(LLMTypesTest, MessageWithToolCalls) { + std::vector calls; + calls.push_back(ToolCall("id1", "tool1", JsonValue::object())); + calls.push_back(ToolCall("id2", "tool2", JsonValue::object())); + + auto msg = Message::assistantWithToolCalls(calls); + EXPECT_EQ(msg.role, Role::ASSISTANT); + EXPECT_TRUE(msg.hasToolCalls()); + EXPECT_EQ(msg.tool_calls->size(), 2u); + EXPECT_EQ((*msg.tool_calls)[0].name, "tool1"); + EXPECT_EQ((*msg.tool_calls)[1].name, "tool2"); +} + +TEST(LLMTypesTest, RoleConversion) { + EXPECT_EQ(roleToString(Role::SYSTEM), "system"); + EXPECT_EQ(roleToString(Role::USER), "user"); + EXPECT_EQ(roleToString(Role::ASSISTANT), "assistant"); + EXPECT_EQ(roleToString(Role::TOOL), "tool"); + + EXPECT_EQ(parseRole("system"), Role::SYSTEM); + EXPECT_EQ(parseRole("user"), Role::USER); + EXPECT_EQ(parseRole("assistant"), Role::ASSISTANT); + EXPECT_EQ(parseRole("tool"), Role::TOOL); + EXPECT_EQ(parseRole("unknown"), Role::USER); // Default +} + +TEST(LLMTypesTest, LLMConfigBuilder) { + LLMConfig config("gpt-4"); + config.withTemperature(0.8) + .withMaxTokens(2000) + .withTopP(0.95) + .withSeed(42) + .withStop({"END", "STOP"}) + .withTimeout(std::chrono::milliseconds(30000)); + + EXPECT_EQ(config.model, "gpt-4"); + EXPECT_TRUE(config.temperature.has_value()); + EXPECT_DOUBLE_EQ(*config.temperature, 0.8); + EXPECT_TRUE(config.max_tokens.has_value()); + EXPECT_EQ(*config.max_tokens, 2000); + EXPECT_TRUE(config.top_p.has_value()); + EXPECT_DOUBLE_EQ(*config.top_p, 0.95); + EXPECT_TRUE(config.seed.has_value()); + EXPECT_EQ(*config.seed, 42); + EXPECT_TRUE(config.stop.has_value()); + EXPECT_EQ(config.stop->size(), 2u); + EXPECT_EQ(config.timeout, std::chrono::milliseconds(30000)); +} + +TEST(LLMTypesTest, LLMResponse) { + LLMResponse response; + response.message = Message::assistant("Hello"); + response.finish_reason = "stop"; + response.usage = Usage(100, 50); + + EXPECT_EQ(response.message.content, "Hello"); + EXPECT_FALSE(response.hasToolCalls()); + EXPECT_TRUE(response.isComplete()); + EXPECT_FALSE(response.isTruncated()); + + EXPECT_TRUE(response.usage.has_value()); + EXPECT_EQ(response.usage->prompt_tokens, 100); + EXPECT_EQ(response.usage->completion_tokens, 50); + EXPECT_EQ(response.usage->total_tokens, 150); +} + +TEST(LLMTypesTest, LLMResponseTruncated) { + LLMResponse response; + response.finish_reason = "length"; + + EXPECT_FALSE(response.isComplete()); + EXPECT_TRUE(response.isTruncated()); +} + +TEST(LLMTypesTest, ToolSpec) { + JsonValue params = JsonValue::object(); + params["type"] = "object"; + JsonValue props = JsonValue::object(); + JsonValue query_prop = JsonValue::object(); + query_prop["type"] = "string"; + props["query"] = query_prop; + params["properties"] = props; + + ToolSpec spec("search", "Search the web", params); + + EXPECT_EQ(spec.name, "search"); + EXPECT_EQ(spec.description, "Search the web"); + EXPECT_TRUE(spec.parameters.contains("type")); + EXPECT_EQ(spec.parameters["type"].getString(), "object"); +} + +// ============================================================================= +// ProviderConfig Tests +// ============================================================================= + +TEST(ProviderConfigTest, Builder) { + ProviderConfig config(ProviderType::OPENAI); + config.withApiKey("sk-test") + .withBaseUrl("https://custom.api.com") + .withHeader("X-Custom", "value"); + + EXPECT_EQ(config.type, ProviderType::OPENAI); + EXPECT_EQ(config.api_key, "sk-test"); + EXPECT_EQ(config.base_url, "https://custom.api.com"); + EXPECT_EQ(config.headers["X-Custom"], "value"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/llm_runnable_test.cc b/third_party/gopher-orch/tests/gopher/orch/llm_runnable_test.cc new file mode 100644 index 00000000..d1cae780 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/llm_runnable_test.cc @@ -0,0 +1,332 @@ +// Unit tests for LLMRunnable + +#include "gopher/orch/llm/llm_runnable.h" + +#include "mock_llm_provider.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +// ============================================================================= +// LLMRunnable Tests +// ============================================================================= + +class LLMRunnableTest : public OrchTest { + protected: + std::shared_ptr mock_provider_; + LLMRunnable::Ptr llm_runnable_; + + void SetUp() override { + OrchTest::SetUp(); + mock_provider_ = makeMockLLMProvider("test-provider"); + llm_runnable_ = LLMRunnable::create(mock_provider_, LLMConfig("gpt-4")); + } +}; + +TEST_F(LLMRunnableTest, Name) { + EXPECT_EQ(llm_runnable_->name(), "LLMRunnable(test-provider)"); +} + +TEST_F(LLMRunnableTest, SimpleStringInput) { + mock_provider_->setDefaultResponse("Hello back!"); + + JsonValue input = "Hello, how are you?"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // Verify output structure + EXPECT_TRUE(result.isObject()); + EXPECT_TRUE(result.contains("message")); + EXPECT_TRUE(result.contains("finish_reason")); + + EXPECT_EQ(result["message"]["content"].getString(), "Hello back!"); + EXPECT_EQ(result["message"]["role"].getString(), "assistant"); + EXPECT_EQ(result["finish_reason"].getString(), "stop"); + + // Verify the provider received correct input + EXPECT_EQ(mock_provider_->lastMessages().size(), 1u); + EXPECT_EQ(mock_provider_->lastMessages()[0].role, Role::USER); + EXPECT_EQ(mock_provider_->lastMessages()[0].content, "Hello, how are you?"); +} + +TEST_F(LLMRunnableTest, MessagesArrayInput) { + mock_provider_->setDefaultResponse("I can help with that."); + + JsonValue input = JsonValue::object(); + JsonValue messages = JsonValue::array(); + + JsonValue system_msg = JsonValue::object(); + system_msg["role"] = "system"; + system_msg["content"] = "You are a helpful assistant."; + messages.push_back(system_msg); + + JsonValue user_msg = JsonValue::object(); + user_msg["role"] = "user"; + user_msg["content"] = "Help me with coding."; + messages.push_back(user_msg); + + input["messages"] = messages; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["message"]["content"].getString(), "I can help with that."); + + // Verify messages were passed correctly + auto last_msgs = mock_provider_->lastMessages(); + EXPECT_EQ(last_msgs.size(), 2u); + EXPECT_EQ(last_msgs[0].role, Role::SYSTEM); + EXPECT_EQ(last_msgs[0].content, "You are a helpful assistant."); + EXPECT_EQ(last_msgs[1].role, Role::USER); + EXPECT_EQ(last_msgs[1].content, "Help me with coding."); +} + +TEST_F(LLMRunnableTest, WithTools) { + mock_provider_->setDefaultResponse("I'll search for that."); + + JsonValue input = JsonValue::object(); + JsonValue messages = JsonValue::array(); + JsonValue user_msg = JsonValue::object(); + user_msg["role"] = "user"; + user_msg["content"] = "Search for weather"; + messages.push_back(user_msg); + input["messages"] = messages; + + // Add tools + JsonValue tools = JsonValue::array(); + JsonValue tool = JsonValue::object(); + tool["name"] = "search"; + tool["description"] = "Search the web"; + JsonValue params = JsonValue::object(); + params["type"] = "object"; + tool["parameters"] = params; + tools.push_back(tool); + input["tools"] = tools; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // Verify tools were passed to provider + auto last_tools = mock_provider_->lastTools(); + EXPECT_EQ(last_tools.size(), 1u); + EXPECT_EQ(last_tools[0].name, "search"); + EXPECT_EQ(last_tools[0].description, "Search the web"); +} + +TEST_F(LLMRunnableTest, ToolCallResponse) { + std::vector tool_calls; + JsonValue args = JsonValue::object(); + args["query"] = "weather in tokyo"; + tool_calls.push_back(ToolCall("call_123", "search", args)); + mock_provider_->queueToolCalls(tool_calls); + + JsonValue input = "What's the weather in Tokyo?"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["finish_reason"].getString(), "tool_calls"); + EXPECT_TRUE(result["message"].contains("tool_calls")); + EXPECT_TRUE(result["message"]["tool_calls"].isArray()); + EXPECT_EQ(result["message"]["tool_calls"].size(), 1u); + + auto tool_call = result["message"]["tool_calls"][0]; + EXPECT_EQ(tool_call["id"].getString(), "call_123"); + EXPECT_EQ(tool_call["name"].getString(), "search"); + EXPECT_EQ(tool_call["arguments"]["query"].getString(), "weather in tokyo"); +} + +TEST_F(LLMRunnableTest, ConfigOverrides) { + mock_provider_->setDefaultResponse("OK"); + + JsonValue input = JsonValue::object(); + JsonValue messages = JsonValue::array(); + JsonValue user_msg = JsonValue::object(); + user_msg["role"] = "user"; + user_msg["content"] = "Hi"; + messages.push_back(user_msg); + input["messages"] = messages; + + // Override config + JsonValue config = JsonValue::object(); + config["model"] = "gpt-3.5-turbo"; + config["temperature"] = 0.5; + config["max_tokens"] = 100; + input["config"] = config; + + runToCompletion([&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + auto last_config = mock_provider_->lastConfig(); + EXPECT_EQ(last_config.model, "gpt-3.5-turbo"); + EXPECT_TRUE(last_config.temperature.has_value()); + EXPECT_DOUBLE_EQ(*last_config.temperature, 0.5); + EXPECT_TRUE(last_config.max_tokens.has_value()); + EXPECT_EQ(*last_config.max_tokens, 100); +} + +TEST_F(LLMRunnableTest, DefaultConfigUsed) { + LLMConfig default_config("claude-3"); + default_config.withTemperature(0.8); + llm_runnable_->setDefaultConfig(default_config); + + mock_provider_->setDefaultResponse("OK"); + + JsonValue input = "Hello"; + + runToCompletion([&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + auto last_config = mock_provider_->lastConfig(); + EXPECT_EQ(last_config.model, "claude-3"); + EXPECT_TRUE(last_config.temperature.has_value()); + EXPECT_DOUBLE_EQ(*last_config.temperature, 0.8); +} + +TEST_F(LLMRunnableTest, UsageIncluded) { + LLMResponse response; + response.message = Message::assistant("Test response"); + response.finish_reason = "stop"; + response.usage = Usage(100, 50); + mock_provider_->queueFullResponse(response); + + JsonValue input = "Test"; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result.contains("usage")); + EXPECT_EQ(result["usage"]["prompt_tokens"].getInt(), 100); + EXPECT_EQ(result["usage"]["completion_tokens"].getInt(), 50); + EXPECT_EQ(result["usage"]["total_tokens"].getInt(), 150); +} + +TEST_F(LLMRunnableTest, ErrorPropagation) { + mock_provider_->queueError(LLMError::RATE_LIMITED, "Rate limit exceeded"); + + JsonValue input = "Test"; + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, LLMError::RATE_LIMITED); + EXPECT_EQ(mcp::get(result).message, "Rate limit exceeded"); +} + +TEST_F(LLMRunnableTest, NoProviderError) { + auto llm_no_provider = LLMRunnable::create(nullptr, LLMConfig("gpt-4")); + + JsonValue input = "Test"; + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + llm_no_provider->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "No LLM provider configured"); +} + +TEST_F(LLMRunnableTest, EmptyMessagesError) { + mock_provider_->setDefaultResponse("OK"); + + // Empty object input with no messages + JsonValue input = JsonValue::object(); + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "No messages provided"); +} + +TEST_F(LLMRunnableTest, ToolResultMessageParsing) { + mock_provider_->setDefaultResponse("Based on the search results..."); + + JsonValue input = JsonValue::object(); + JsonValue messages = JsonValue::array(); + + // User message + JsonValue user_msg = JsonValue::object(); + user_msg["role"] = "user"; + user_msg["content"] = "Search for weather"; + messages.push_back(user_msg); + + // Assistant message with tool calls + JsonValue assistant_msg = JsonValue::object(); + assistant_msg["role"] = "assistant"; + assistant_msg["content"] = ""; + JsonValue tool_calls = JsonValue::array(); + JsonValue call = JsonValue::object(); + call["id"] = "call_123"; + call["name"] = "search"; + JsonValue args = JsonValue::object(); + args["query"] = "weather"; + call["arguments"] = args; + tool_calls.push_back(call); + assistant_msg["tool_calls"] = tool_calls; + messages.push_back(assistant_msg); + + // Tool result message + JsonValue tool_msg = JsonValue::object(); + tool_msg["role"] = "tool"; + tool_msg["content"] = "Sunny, 25C"; + tool_msg["tool_call_id"] = "call_123"; + messages.push_back(tool_msg); + + input["messages"] = messages; + + runToCompletion([&](Dispatcher& d, ResultCallback cb) { + llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + auto last_msgs = mock_provider_->lastMessages(); + EXPECT_EQ(last_msgs.size(), 3u); + + // Verify tool result message + EXPECT_EQ(last_msgs[2].role, Role::TOOL); + EXPECT_EQ(last_msgs[2].content, "Sunny, 25C"); + EXPECT_TRUE(last_msgs[2].tool_call_id.has_value()); + EXPECT_EQ(*last_msgs[2].tool_call_id, "call_123"); + + // Verify assistant message with tool calls + EXPECT_EQ(last_msgs[1].role, Role::ASSISTANT); + EXPECT_TRUE(last_msgs[1].hasToolCalls()); + EXPECT_EQ(last_msgs[1].tool_calls->size(), 1u); + EXPECT_EQ((*last_msgs[1].tool_calls)[0].name, "search"); +} + +TEST_F(LLMRunnableTest, Accessors) { + EXPECT_EQ(llm_runnable_->provider(), mock_provider_); + EXPECT_EQ(llm_runnable_->defaultConfig().model, "gpt-4"); +} + +// ============================================================================= +// Factory Function Test +// ============================================================================= + +TEST_F(LLMRunnableTest, MakeLLMRunnable) { + auto llm = makeLLMRunnable(mock_provider_, LLMConfig("test-model")); + EXPECT_NE(llm, nullptr); + EXPECT_EQ(llm->provider(), mock_provider_); + EXPECT_EQ(llm->defaultConfig().model, "test-model"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/mcp_server_test.cc b/third_party/gopher-orch/tests/gopher/orch/mcp_server_test.cc new file mode 100644 index 00000000..8f8f11ba --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/mcp_server_test.cc @@ -0,0 +1,106 @@ +// Unit tests for MCPServer +// +// Tests MCPServer configuration, creation, and integration with +// ServerComposite. Note: Full integration tests require actual MCP server +// connections. + +#include "orch_test_fixture.h" + +#ifdef GOPHER_ORCH_WITH_MCP +#include "gopher/orch/server/mcp_server.h" +#endif + +// ============================================================================= +// MCPServer Configuration Tests +// ============================================================================= + +#ifdef GOPHER_ORCH_WITH_MCP + +TEST_F(OrchTest, MCPServerConfigDefaults) { + // Test that MCPServerConfig has sensible defaults + server::MCPServerConfig config; + config.name = "test-server"; + + EXPECT_EQ(config.name, "test-server"); + EXPECT_EQ(config.transport_type, + server::MCPServerConfig::TransportType::STDIO); + EXPECT_EQ(config.client_name, "gopher-orch"); + EXPECT_EQ(config.client_version, "1.0.0"); + EXPECT_EQ(config.max_connect_retries, 3u); + EXPECT_EQ(config.connect_timeout.count(), 30000); + EXPECT_EQ(config.request_timeout.count(), 60000); +} + +TEST_F(OrchTest, MCPServerConfigStdioTransport) { + // Test stdio transport configuration + server::MCPServerConfig config; + config.name = "npx-server"; + config.transport_type = server::MCPServerConfig::TransportType::STDIO; + config.stdio_transport.command = "npx"; + config.stdio_transport.args = {"-y", + "@modelcontextprotocol/server-everything"}; + config.stdio_transport.env["NODE_ENV"] = "production"; + + EXPECT_EQ(config.stdio_transport.command, "npx"); + EXPECT_EQ(config.stdio_transport.args.size(), 2u); + EXPECT_EQ(config.stdio_transport.args[0], "-y"); + EXPECT_EQ(config.stdio_transport.env["NODE_ENV"], "production"); +} + +TEST_F(OrchTest, MCPServerConfigHttpSseTransport) { + // Test HTTP+SSE transport configuration + server::MCPServerConfig config; + config.name = "remote-server"; + config.transport_type = server::MCPServerConfig::TransportType::HTTP_SSE; + config.http_sse_transport.url = "https://api.example.com/mcp"; + config.http_sse_transport.headers["Authorization"] = "Bearer token123"; + config.http_sse_transport.verify_ssl = true; + + EXPECT_EQ(config.http_sse_transport.url, "https://api.example.com/mcp"); + EXPECT_EQ(config.http_sse_transport.headers["Authorization"], + "Bearer token123"); + EXPECT_TRUE(config.http_sse_transport.verify_ssl); +} + +TEST_F(OrchTest, MCPServerWithComposite) { + // Test that Server interface can be used with ServerComposite + // Uses mock server since MCPServer requires actual MCP connection + auto mockServer = makeMockServer("mcp-like-server"); + mockServer->addTool("get_weather", "Get weather for a location"); + mockServer->setHandler("get_weather", + [](const JsonValue& args) -> Result { + JsonValue result = JsonValue::object(); + result["temperature"] = JsonValue(72); + result["location"] = args["city"]; + return makeSuccess(JsonValue(result)); + }); + + // Create composite and add the server + auto composite = ServerComposite::create("multi-server"); + std::vector tools = {"get_weather"}; + composite->addServer(mockServer, tools, true); + + // Connect + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + composite->connectAll(d, std::move(cb)); + }); + + // Get tool through composite + auto weatherTool = composite->tool("mcp-like-server.get_weather"); + ASSERT_NE(weatherTool, nullptr); + + // Invoke the tool + JsonValue input = JsonValue::object(); + input["city"] = JsonValue("Seattle"); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + weatherTool->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["temperature"].getInt(), 72); + EXPECT_EQ(result["location"].getString(), "Seattle"); +} + +#endif // GOPHER_ORCH_WITH_MCP diff --git a/third_party/gopher-orch/tests/gopher/orch/mock_http_client.h b/third_party/gopher-orch/tests/gopher/orch/mock_http_client.h new file mode 100644 index 00000000..62d1dded --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/mock_http_client.h @@ -0,0 +1,232 @@ +// MockHttpClient - Mock HTTP client for testing REST endpoints +// +// Provides configurable HTTP responses for testing without network calls. +// Supports: +// - Pre-configured responses per URL/method +// - Request recording for verification +// - Error simulation +// - Response delays + +#pragma once + +#include +#include +#include +#include + +#include "gopher/orch/server/rest_server.h" + +namespace gopher { +namespace orch { +namespace server { + +// Request record for verification +struct HttpRequestRecord { + HttpMethod method; + std::string url; + std::map headers; + std::string body; +}; + +// Mock response configuration +struct MockHttpResponseConfig { + HttpResponse response; + optional error; + std::chrono::milliseconds delay{0}; +}; + +// MockHttpClient - In-memory HTTP client for testing +class MockHttpClient : public HttpClient { + public: + MockHttpClient() = default; + + void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + ResponseCallback callback) override { + std::lock_guard lock(mutex_); + + // Record the request + HttpRequestRecord record; + record.method = method; + record.url = url; + record.headers = headers; + record.body = body; + requests_.push_back(record); + + // Build key for response lookup + std::string key = httpMethodToString(method) + " " + url; + + // Look for exact match first, then prefix match + MockHttpResponseConfig response_config; + auto it = responses_.find(key); + if (it != responses_.end()) { + response_config = it->second; + } else { + // Try prefix match + for (const auto& kv : responses_) { + if (key.find(kv.first) == 0 || kv.first.find(key) == 0) { + response_config = kv.second; + break; + } + } + // If no match and default is set + if (default_response_.has_value()) { + response_config.response = *default_response_; + } else { + // Default 404 response + response_config.response.status_code = 404; + response_config.response.body = "{\"error\": \"Not found\"}"; + } + } + + // Schedule response + if (response_config.delay.count() > 0) { + auto timer = dispatcher.createTimer([callback = std::move(callback), + response_config]() mutable { + if (response_config.error.has_value()) { + callback(Result(*response_config.error)); + } else { + callback(Result(std::move(response_config.response))); + } + }); + timer->enableTimer(response_config.delay); + } else { + dispatcher.post([callback = std::move(callback), + response_config]() mutable { + if (response_config.error.has_value()) { + callback(Result(*response_config.error)); + } else { + callback(Result(std::move(response_config.response))); + } + }); + } + } + + // ========================================================================= + // MockHttpClient-specific API for test configuration + // ========================================================================= + + // Set response for a specific URL/method + MockHttpClient& setResponse(HttpMethod method, + const std::string& url, + int status_code, + const std::string& body) { + std::lock_guard lock(mutex_); + std::string key = httpMethodToString(method) + " " + url; + MockHttpResponseConfig config; + config.response.status_code = status_code; + config.response.body = body; + responses_[key] = config; + return *this; + } + + // Set response with headers + MockHttpClient& setResponse( + HttpMethod method, + const std::string& url, + int status_code, + const std::string& body, + const std::map& headers) { + std::lock_guard lock(mutex_); + std::string key = httpMethodToString(method) + " " + url; + MockHttpResponseConfig config; + config.response.status_code = status_code; + config.response.body = body; + config.response.headers = headers; + responses_[key] = config; + return *this; + } + + // Set error for a specific URL/method + MockHttpClient& setError(HttpMethod method, + const std::string& url, + int code, + const std::string& message) { + std::lock_guard lock(mutex_); + std::string key = httpMethodToString(method) + " " + url; + MockHttpResponseConfig config; + config.error = Error(code, message); + responses_[key] = config; + return *this; + } + + // Set default response for unmatched requests + MockHttpClient& setDefaultResponse(int status_code, const std::string& body) { + std::lock_guard lock(mutex_); + HttpResponse response; + response.status_code = status_code; + response.body = body; + default_response_ = response; + return *this; + } + + // Set response delay + MockHttpClient& setDelay(HttpMethod method, + const std::string& url, + std::chrono::milliseconds delay) { + std::lock_guard lock(mutex_); + std::string key = httpMethodToString(method) + " " + url; + if (responses_.find(key) != responses_.end()) { + responses_[key].delay = delay; + } + return *this; + } + + // Get all recorded requests + std::vector requests() const { + std::lock_guard lock(mutex_); + return requests_; + } + + // Get request count + size_t requestCount() const { + std::lock_guard lock(mutex_); + return requests_.size(); + } + + // Get last request + optional lastRequest() const { + std::lock_guard lock(mutex_); + if (requests_.empty()) { + return nullopt; + } + return requests_.back(); + } + + // Check if a specific URL was called + bool wasCalled(HttpMethod method, const std::string& url) const { + std::lock_guard lock(mutex_); + for (const auto& req : requests_) { + if (req.method == method && req.url == url) { + return true; + } + } + return false; + } + + // Reset mock state + void reset() { + std::lock_guard lock(mutex_); + requests_.clear(); + responses_.clear(); + default_response_ = nullopt; + } + + private: + mutable std::mutex mutex_; + std::vector requests_; + std::map responses_; + optional default_response_; +}; + +// Factory function +inline std::shared_ptr makeMockHttpClient() { + return std::make_shared(); +} + +} // namespace server +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/tests/gopher/orch/mock_llm_provider.h b/third_party/gopher-orch/tests/gopher/orch/mock_llm_provider.h new file mode 100644 index 00000000..ff0fa33b --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/mock_llm_provider.h @@ -0,0 +1,238 @@ +// MockLLMProvider - Mock LLM provider for testing agents and tool execution +// +// Provides configurable responses for testing without network calls. +// Supports: +// - Pre-configured responses +// - Tool call simulation +// - Response sequences +// - Error simulation + +#pragma once + +#include +#include +#include +#include + +#include "gopher/orch/llm/llm_provider.h" + +namespace gopher { +namespace orch { +namespace llm { + +// Mock response configuration +struct MockResponseConfig { + LLMResponse response; + optional error; + std::chrono::milliseconds delay{0}; +}; + +// MockLLMProvider - In-memory LLM provider for testing +class MockLLMProvider : public LLMProvider { + public: + using Ptr = std::shared_ptr; + + explicit MockLLMProvider(const std::string& name = "mock-llm") + : name_(name) {} + + // LLMProvider interface + std::string name() const override { return name_; } + + void chat(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) override { + std::lock_guard lock(mutex_); + + call_count_++; + last_messages_ = messages; + last_tools_ = tools; + last_config_ = config; + + // Get next response from queue, or use default + MockResponseConfig response_config; + if (!response_queue_.empty()) { + response_config = response_queue_.front(); + response_queue_.pop(); + } else if (default_response_.has_value()) { + response_config.response = *default_response_; + } else { + // Default: return empty response + response_config.response.message = + Message::assistant("Default mock response"); + response_config.response.finish_reason = "stop"; + } + + // Schedule response with optional delay + if (response_config.delay.count() > 0) { + auto timer = dispatcher.createTimer([callback = std::move(callback), + response_config]() mutable { + if (response_config.error.has_value()) { + callback(Result(*response_config.error)); + } else { + callback(Result(std::move(response_config.response))); + } + }); + timer->enableTimer(response_config.delay); + } else { + dispatcher.post([callback = std::move(callback), + response_config]() mutable { + if (response_config.error.has_value()) { + callback(Result(*response_config.error)); + } else { + callback(Result(std::move(response_config.response))); + } + }); + } + } + + void chatStream(const std::vector& messages, + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + StreamCallback on_chunk, + ChatCallback on_complete) override { + // Fall back to non-streaming + chat(messages, tools, config, dispatcher, std::move(on_complete)); + } + + bool isModelSupported(const std::string& model) const override { + return !model.empty(); + } + + std::vector supportedModels() const override { + return {"mock-model", "test-model"}; + } + + std::string endpoint() const override { return "mock://localhost/v1/chat"; } + + bool isConfigured() const override { return true; } + + // ========================================================================= + // MockLLMProvider-specific API for test configuration + // ========================================================================= + + // Set default response for all calls + MockLLMProvider& setDefaultResponse(const std::string& content) { + std::lock_guard lock(mutex_); + LLMResponse response; + response.message = Message::assistant(content); + response.finish_reason = "stop"; + default_response_ = response; + return *this; + } + + // Set default response with tool calls + MockLLMProvider& setDefaultToolCalls( + const std::vector& tool_calls) { + std::lock_guard lock(mutex_); + LLMResponse response; + response.message = Message::assistantWithToolCalls(tool_calls); + response.finish_reason = "tool_calls"; + default_response_ = response; + return *this; + } + + // Queue a response (FIFO order) + MockLLMProvider& queueResponse(const std::string& content) { + std::lock_guard lock(mutex_); + MockResponseConfig config; + config.response.message = Message::assistant(content); + config.response.finish_reason = "stop"; + response_queue_.push(config); + return *this; + } + + // Queue a tool call response + MockLLMProvider& queueToolCalls(const std::vector& tool_calls) { + std::lock_guard lock(mutex_); + MockResponseConfig config; + config.response.message = Message::assistantWithToolCalls(tool_calls); + config.response.finish_reason = "tool_calls"; + response_queue_.push(config); + return *this; + } + + // Queue an error response + MockLLMProvider& queueError(int code, const std::string& message) { + std::lock_guard lock(mutex_); + MockResponseConfig config; + config.error = Error(code, message); + response_queue_.push(config); + return *this; + } + + // Queue a full LLMResponse + MockLLMProvider& queueFullResponse(const LLMResponse& response) { + std::lock_guard lock(mutex_); + MockResponseConfig config; + config.response = response; + response_queue_.push(config); + return *this; + } + + // Set response delay + MockLLMProvider& setDelay(std::chrono::milliseconds delay) { + std::lock_guard lock(mutex_); + delay_ = delay; + return *this; + } + + // Get call count + size_t callCount() const { + std::lock_guard lock(mutex_); + return call_count_; + } + + // Get last messages received + std::vector lastMessages() const { + std::lock_guard lock(mutex_); + return last_messages_; + } + + // Get last tools received + std::vector lastTools() const { + std::lock_guard lock(mutex_); + return last_tools_; + } + + // Get last config received + LLMConfig lastConfig() const { + std::lock_guard lock(mutex_); + return last_config_; + } + + // Reset mock state + void reset() { + std::lock_guard lock(mutex_); + call_count_ = 0; + last_messages_.clear(); + last_tools_.clear(); + default_response_ = nullopt; + while (!response_queue_.empty()) { + response_queue_.pop(); + } + } + + private: + mutable std::mutex mutex_; + std::string name_; + size_t call_count_ = 0; + std::vector last_messages_; + std::vector last_tools_; + LLMConfig last_config_; + optional default_response_; + std::queue response_queue_; + std::chrono::milliseconds delay_{0}; +}; + +// Factory function +inline std::shared_ptr makeMockLLMProvider( + const std::string& name = "mock-llm") { + return std::make_shared(name); +} + +} // namespace llm +} // namespace orch +} // namespace gopher diff --git a/third_party/gopher-orch/tests/gopher/orch/mock_server_test.cc b/third_party/gopher-orch/tests/gopher/orch/mock_server_test.cc new file mode 100644 index 00000000..ca2d5daf --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/mock_server_test.cc @@ -0,0 +1,105 @@ +// Unit tests for MockServer + +#include "orch_test_fixture.h" + +// ============================================================================= +// MockServer Tests +// ============================================================================= + +TEST_F(OrchTest, MockServerBasic) { + auto server = makeMockServer("test-server"); + + JsonValue response = JsonValue::object(); + response["message"] = JsonValue("Hello!"); + + server->addTool("greet", "Greets a person").setResponse("greet", response); + + EXPECT_EQ(server->name(), "test-server"); + EXPECT_EQ(server->connectionState(), ConnectionState::DISCONNECTED); + + // Connect + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + EXPECT_TRUE(server->isConnected()); + + // List tools + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { + server->listTools(d, std::move(cb)); + }); + + EXPECT_EQ(tools.size(), 1u); + EXPECT_EQ(tools[0].name, "greet"); + + // Get tool + auto greet = server->tool("greet"); + EXPECT_NE(greet, nullptr); + EXPECT_EQ(greet->name(), "greet"); + + // Call tool + JsonValue toolResult = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + greet->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(toolResult["message"].getString(), "Hello!"); + EXPECT_EQ(server->callCount("greet"), 1u); +} + +TEST_F(OrchTest, MockServerCustomHandler) { + auto server = makeMockServer("handler-server"); + + server->addTool("echo").setHandler( + "echo", [](const JsonValue& args) -> Result { + JsonValue result = JsonValue::object(); + result["echoed"] = args; + return makeSuccess(JsonValue(result)); + }); + + server->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + auto echo = server->tool("echo"); + + JsonValue input = JsonValue::object(); + input["data"] = JsonValue("test"); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + echo->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["echoed"]["data"].getString(), "test"); +} + +TEST_F(OrchTest, MockServerToolNotFound) { + auto server = makeMockServer("empty-server"); + server->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + EXPECT_EQ(server->tool("nonexistent"), nullptr); +} + +TEST_F(OrchTest, MockServerError) { + auto server = makeMockServer("error-server"); + + server->addTool("fail").setError("fail", OrchError::INTERNAL_ERROR, + "Simulated failure"); + + server->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + auto fail = server->tool("fail"); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + fail->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::INTERNAL_ERROR); + EXPECT_EQ(mcp::get(result).message, "Simulated failure"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/orch_test_fixture.h b/third_party/gopher-orch/tests/gopher/orch/orch_test_fixture.h new file mode 100644 index 00000000..752448bd --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/orch_test_fixture.h @@ -0,0 +1,95 @@ +#pragma once + +// Shared test fixture for gopher-orch unit tests +// Provides common dispatcher setup and async helpers + +#include +#include +#include +#include +#include + +#include "mcp/event/libevent_dispatcher.h" + +#include "gopher/orch/orch.h" +#include "gtest/gtest.h" + +using namespace gopher::orch; +using namespace gopher::orch::core; +using namespace gopher::orch::composition; +using namespace gopher::orch::resilience; +using namespace gopher::orch::server; + +// Test fixture with dispatcher +class OrchTest : public ::testing::Test { + protected: + void SetUp() override { + dispatcher_ = std::make_unique("test"); + } + + void TearDown() override { dispatcher_.reset(); } + + // Run dispatcher until callback completes + template + T runToCompletion( + std::function)> operation) { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + Result result = Result(Error(-1, "Not completed")); + + operation(*dispatcher_, [&](Result r) { + std::lock_guard lock(mutex); + result = std::move(r); + done = true; + cv.notify_one(); + }); + + // Run dispatcher until done + while (true) { + { + std::unique_lock lock(mutex); + if (done) + break; + } + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + EXPECT_TRUE(mcp::holds_alternative(result)) + << "Operation failed: " << mcp::get(result).message; + return mcp::get(result); + } + + // Run dispatcher until callback completes (allow error) + template + Result runToCompletionResult( + std::function)> operation) { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + Result result = Result(Error(-1, "Not completed")); + + operation(*dispatcher_, [&](Result r) { + std::lock_guard lock(mutex); + result = std::move(r); + done = true; + cv.notify_one(); + }); + + // Run dispatcher until done + while (true) { + { + std::unique_lock lock(mutex); + if (done) + break; + } + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + return result; + } + + std::unique_ptr dispatcher_; +}; diff --git a/third_party/gopher-orch/tests/gopher/orch/parallel_test.cc b/third_party/gopher-orch/tests/gopher/orch/parallel_test.cc new file mode 100644 index 00000000..9434cade --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/parallel_test.cc @@ -0,0 +1,84 @@ +// Unit tests for Parallel composition pattern + +#include "orch_test_fixture.h" + +// ============================================================================= +// Parallel Tests +// ============================================================================= + +TEST_F(OrchTest, ParallelBasic) { + auto branchA = makeJsonLambda( + [](const JsonValue& input) -> Result { + JsonValue result = JsonValue::object(); + result["a_result"] = JsonValue(input["value"].getInt() + 1); + return makeSuccess(JsonValue(result)); + }, + "BranchA"); + + auto branchB = makeJsonLambda( + [](const JsonValue& input) -> Result { + JsonValue result = JsonValue::object(); + result["b_result"] = JsonValue(input["value"].getInt() * 2); + return makeSuccess(JsonValue(result)); + }, + "BranchB"); + + auto par = + parallel("TestParallel").add("a", branchA).add("b", branchB).build(); + + EXPECT_EQ(par->size(), 2u); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + JsonValue input = JsonValue::object(); + input["value"] = JsonValue(10); + par->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // Check both branches executed + EXPECT_EQ(result["a"]["a_result"].getInt(), 11); // 10 + 1 + EXPECT_EQ(result["b"]["b_result"].getInt(), 20); // 10 * 2 +} + +TEST_F(OrchTest, ParallelFailFast) { + std::atomic branchB_completed{0}; + + auto branchA = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result( + Error(OrchError::INTERNAL_ERROR, "Branch A failed")); + }, + "FailingBranch"); + + auto branchB = makeJsonLambda( + [&branchB_completed](const JsonValue&) -> Result { + branchB_completed++; + JsonValue result = JsonValue::object(); + result["ok"] = JsonValue(true); + return makeSuccess(JsonValue(result)); + }, + "BranchB"); + + auto par = parallel().add("a", branchA).add("b", branchB).build(); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + par->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "Branch A failed"); + // Note: branchB may or may not complete depending on timing +} + +TEST_F(OrchTest, ParallelEmpty) { + auto par = parallel().build(); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + par->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + // Empty parallel returns empty object + EXPECT_TRUE(result.isObject()); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/rest_server_test.cc b/third_party/gopher-orch/tests/gopher/orch/rest_server_test.cc new file mode 100644 index 00000000..f054fe9e --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/rest_server_test.cc @@ -0,0 +1,517 @@ +// Unit tests for RESTServer +// +// Tests REST server configuration, URL building, and integration with +// ServerComposite. Uses a mock HTTP client for isolated testing. + +#include "gopher/orch/server/rest_server.h" + +#include "orch_test_fixture.h" + +using namespace gopher::orch::server; + +// ============================================================================= +// Mock HTTP Client for Testing +// ============================================================================= + +class MockHttpClient : public HttpClient { + public: + struct RecordedRequest { + HttpMethod method; + std::string url; + std::map headers; + std::string body; + }; + + void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + ResponseCallback callback) override { + RecordedRequest req{method, url, headers, body}; + requests_.push_back(req); + + // Find matching response + HttpResponse response; + auto it = responses_.find(url); + if (it != responses_.end()) { + response = it->second; + } else if (default_response_.status_code != 0) { + response = default_response_; + } else { + response.status_code = 200; + response.body = "{}"; + } + + dispatcher.post( + [callback, response]() { callback(Result(response)); }); + } + + // Set response for a specific URL + void setResponse(const std::string& url, const HttpResponse& response) { + responses_[url] = response; + } + + // Set default response for any URL + void setDefaultResponse(const HttpResponse& response) { + default_response_ = response; + } + + // Set error response + void setError(const std::string& url, const Error& error) { + error_ = error; + error_url_ = url; + } + + // Get recorded requests + const std::vector& requests() const { return requests_; } + + // Clear recorded requests + void clearRequests() { requests_.clear(); } + + private: + std::vector requests_; + std::map responses_; + HttpResponse default_response_; + Error error_; + std::string error_url_; +}; + +// ============================================================================= +// RESTServer Configuration Tests +// ============================================================================= + +TEST_F(OrchTest, RESTServerConfigDefaults) { + RESTServerConfig config; + config.name = "test-api"; + config.base_url = "https://api.example.com/v1"; + + EXPECT_EQ(config.name, "test-api"); + EXPECT_EQ(config.base_url, "https://api.example.com/v1"); + EXPECT_EQ(config.auth.type, RESTServerConfig::AuthConfig::Type::NONE); + EXPECT_EQ(config.connect_timeout.count(), 10000); + EXPECT_EQ(config.request_timeout.count(), 30000); + EXPECT_TRUE(config.verify_ssl); +} + +TEST_F(OrchTest, RESTServerConfigFluentAPI) { + RESTServerConfig config; + config.name = "fluent-api"; + ; + config.base_url = "https://api.example.com"; + + config.addTool("get_users", "GET", "/users", "Get all users") + .addTool("create_user", "POST", "/users", "Create a user") + .addTool("get_user", "GET", "/users/{id}", "Get user by ID") + .setHeader("X-Custom", "value") + .setBearerAuth("token123"); + + EXPECT_EQ(config.tools.size(), 3u); + EXPECT_TRUE(config.tools.count("get_users") > 0); + EXPECT_TRUE(config.tools.count("create_user") > 0); + EXPECT_TRUE(config.tools.count("get_user") > 0); + + EXPECT_EQ(config.tools["get_users"].method, HttpMethod::GET); + EXPECT_EQ(config.tools["create_user"].method, HttpMethod::POST); + EXPECT_EQ(config.tools["get_user"].path, "/users/{id}"); + + EXPECT_EQ(config.default_headers["X-Custom"], "value"); + EXPECT_EQ(config.auth.type, RESTServerConfig::AuthConfig::Type::BEARER); + EXPECT_EQ(config.auth.bearer_token, "token123"); +} + +TEST_F(OrchTest, RESTServerConfigAuthTypes) { + RESTServerConfig config; + config.name = "auth-test"; + config.base_url = "https://api.example.com"; + + // Bearer auth + config.setBearerAuth("my-token"); + EXPECT_EQ(config.auth.type, RESTServerConfig::AuthConfig::Type::BEARER); + EXPECT_EQ(config.auth.bearer_token, "my-token"); + + // Basic auth + config.setBasicAuth("user", "pass"); + EXPECT_EQ(config.auth.type, RESTServerConfig::AuthConfig::Type::BASIC); + EXPECT_EQ(config.auth.username, "user"); + EXPECT_EQ(config.auth.password, "pass"); + + // API key auth + config.setApiKey("api-key-123", "X-API-Key"); + EXPECT_EQ(config.auth.type, RESTServerConfig::AuthConfig::Type::API_KEY); + EXPECT_EQ(config.auth.api_key, "api-key-123"); + EXPECT_EQ(config.auth.api_key_header, "X-API-Key"); +} + +// ============================================================================= +// RESTServer Creation Tests +// ============================================================================= + +TEST_F(OrchTest, RESTServerCreate) { + RESTServerConfig config; + config.name = "test-server"; + config.base_url = "https://api.example.com"; + config.addTool("test_tool", "GET", "/test"); + + auto mockClient = std::make_shared(); + auto server = RESTServer::create(config, mockClient); + + EXPECT_NE(server, nullptr); + EXPECT_EQ(server->name(), "test-server"); + EXPECT_EQ(server->connectionState(), ConnectionState::DISCONNECTED); +} + +TEST_F(OrchTest, RESTServerConnect) { + RESTServerConfig config; + config.name = "connect-test"; + config.base_url = "https://api.example.com"; + + auto mockClient = std::make_shared(); + auto server = RESTServer::create(config, mockClient); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + EXPECT_EQ(server->connectionState(), ConnectionState::CONNECTED); +} + +TEST_F(OrchTest, RESTServerConnectFailsWithoutBaseUrl) { + RESTServerConfig config; + config.name = "no-base-url"; + // base_url not set + + auto mockClient = std::make_shared(); + auto server = RESTServer::create(config, mockClient); + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); +} + +TEST_F(OrchTest, RESTServerListTools) { + RESTServerConfig config; + config.name = "list-tools-test"; + config.base_url = "https://api.example.com"; + config.addTool("tool1", "GET", "/t1", "Tool 1") + .addTool("tool2", "POST", "/t2", "Tool 2"); + + auto mockClient = std::make_shared(); + auto server = RESTServer::create(config, mockClient); + + // Connect first + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { + server->listTools(d, std::move(cb)); + }); + + EXPECT_EQ(tools.size(), 2u); +} + +TEST_F(OrchTest, RESTServerGetTool) { + RESTServerConfig config; + config.name = "get-tool-test"; + config.base_url = "https://api.example.com"; + config.addTool("my_tool", "GET", "/my-endpoint"); + + auto mockClient = std::make_shared(); + auto server = RESTServer::create(config, mockClient); + + auto tool = server->tool("my_tool"); + EXPECT_NE(tool, nullptr); + EXPECT_EQ(tool->name(), "my_tool"); + + // Non-existent tool + auto missing = server->tool("nonexistent"); + EXPECT_EQ(missing, nullptr); +} + +TEST_F(OrchTest, RESTServerToolCaching) { + RESTServerConfig config; + config.name = "cache-test"; + config.base_url = "https://api.example.com"; + config.addTool("cached_tool", "GET", "/cached"); + + auto mockClient = std::make_shared(); + auto server = RESTServer::create(config, mockClient); + + auto tool1 = server->tool("cached_tool"); + auto tool2 = server->tool("cached_tool"); + + // Should return same cached instance + EXPECT_EQ(tool1.get(), tool2.get()); +} + +// ============================================================================= +// RESTServer Tool Invocation Tests +// ============================================================================= + +TEST_F(OrchTest, RESTServerCallToolGet) { + RESTServerConfig config; + config.name = "call-test"; + config.base_url = "http://localhost:8080"; + config.addTool("get_data", "GET", "/data"); + + auto mockClient = std::make_shared(); + HttpResponse mockResponse; + mockResponse.status_code = 200; + mockResponse.body = R"({"result": "success"})"; + mockClient->setDefaultResponse(mockResponse); + + auto server = RESTServer::create(config, mockClient); + + // Connect + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + // Call tool + JsonValue input = JsonValue::object(); + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + server->callTool("get_data", input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["result"].getString(), "success"); + + // Verify request was made + EXPECT_EQ(mockClient->requests().size(), 1u); + EXPECT_EQ(mockClient->requests()[0].method, HttpMethod::GET); + EXPECT_EQ(mockClient->requests()[0].url, "http://localhost:8080/data"); +} + +TEST_F(OrchTest, RESTServerCallToolPost) { + RESTServerConfig config; + config.name = "post-test"; + config.base_url = "http://localhost:8080"; + config.addTool("create_item", "POST", "/items"); + + auto mockClient = std::make_shared(); + HttpResponse mockResponse; + mockResponse.status_code = 201; + mockResponse.body = R"({"id": 123})"; + mockClient->setDefaultResponse(mockResponse); + + auto server = RESTServer::create(config, mockClient); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + JsonValue input = JsonValue::object(); + input["name"] = JsonValue("test item"); + + JsonValue result = runToCompletion([&](Dispatcher& d, + JsonCallback cb) { + server->callTool("create_item", input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["id"].getInt(), 123); + + // Verify request + EXPECT_EQ(mockClient->requests()[0].method, HttpMethod::POST); + EXPECT_EQ(mockClient->requests()[0].headers.at("Content-Type"), + "application/json"); + EXPECT_FALSE(mockClient->requests()[0].body.empty()); +} + +TEST_F(OrchTest, RESTServerCallToolWithPathParams) { + RESTServerConfig config; + config.name = "path-params-test"; + config.base_url = "http://localhost:8080"; + config.addTool("get_user", "GET", "/users/{user_id}/posts/{post_id}"); + + auto mockClient = std::make_shared(); + HttpResponse mockResponse; + mockResponse.status_code = 200; + mockResponse.body = R"({"title": "Hello"})"; + mockClient->setDefaultResponse(mockResponse); + + auto server = RESTServer::create(config, mockClient); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + JsonValue input = JsonValue::object(); + input["user_id"] = JsonValue("42"); + input["post_id"] = JsonValue(123); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + server->callTool("get_user", input, RunnableConfig(), d, std::move(cb)); + }); + + // Verify URL with substituted path parameters + EXPECT_EQ(mockClient->requests()[0].url, + "http://localhost:8080/users/42/posts/123"); +} + +TEST_F(OrchTest, RESTServerCallToolNotFound) { + RESTServerConfig config; + config.name = "not-found-test"; + config.base_url = "http://localhost:8080"; + config.addTool("existing_tool", "GET", "/exists"); + + auto mockClient = std::make_shared(); + auto server = RESTServer::create(config, mockClient); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + server->callTool("nonexistent_tool", JsonValue::object(), + RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); +} + +TEST_F(OrchTest, RESTServerCallToolHttpError) { + RESTServerConfig config; + config.name = "http-error-test"; + config.base_url = "http://localhost:8080"; + config.addTool("error_tool", "GET", "/error"); + + auto mockClient = std::make_shared(); + HttpResponse mockResponse; + mockResponse.status_code = 500; + mockResponse.body = "Internal Server Error"; + mockClient->setDefaultResponse(mockResponse); + + auto server = RESTServer::create(config, mockClient); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + server->callTool("error_tool", JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); +} + +// ============================================================================= +// RESTServer Authentication Tests +// ============================================================================= + +TEST_F(OrchTest, RESTServerBearerAuth) { + RESTServerConfig config; + config.name = "bearer-auth-test"; + config.base_url = "http://localhost:8080"; + config.setBearerAuth("my-secret-token"); + config.addTool("auth_tool", "GET", "/protected"); + + auto mockClient = std::make_shared(); + HttpResponse mockResponse; + mockResponse.status_code = 200; + mockResponse.body = "{}"; + mockClient->setDefaultResponse(mockResponse); + + auto server = RESTServer::create(config, mockClient); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + server->callTool("auth_tool", JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + // Verify Authorization header + EXPECT_EQ(mockClient->requests()[0].headers.at("Authorization"), + "Bearer my-secret-token"); +} + +TEST_F(OrchTest, RESTServerApiKeyAuth) { + RESTServerConfig config; + config.name = "api-key-test"; + config.base_url = "http://localhost:8080"; + config.setApiKey("secret-api-key", "X-API-Key"); + config.addTool("api_tool", "GET", "/api"); + + auto mockClient = std::make_shared(); + HttpResponse mockResponse; + mockResponse.status_code = 200; + mockResponse.body = "{}"; + mockClient->setDefaultResponse(mockResponse); + + auto server = RESTServer::create(config, mockClient); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + server->connect(d, std::move(cb)); + }); + + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + server->callTool("api_tool", JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + // Verify API key header + EXPECT_EQ(mockClient->requests()[0].headers.at("X-API-Key"), + "secret-api-key"); +} + +// ============================================================================= +// RESTServer with ServerComposite Tests +// ============================================================================= + +TEST_F(OrchTest, RESTServerWithComposite) { + RESTServerConfig config; + config.name = "rest-api"; + config.base_url = "http://localhost:8080"; + config.addTool("get_items", "GET", "/items"); + + auto mockClient = std::make_shared(); + HttpResponse mockResponse; + mockResponse.status_code = 200; + mockResponse.body = R"({"items": [1, 2, 3]})"; + mockClient->setDefaultResponse(mockResponse); + + auto restServer = RESTServer::create(config, mockClient); + + // Create composite with REST server + auto composite = ServerComposite::create("multi-server"); + std::vector tools = {"get_items"}; + composite->addServer(restServer, tools, true); + + // Connect all + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + composite->connectAll(d, std::move(cb)); + }); + + // Get tool through composite + auto tool = composite->tool("rest-api.get_items"); + EXPECT_NE(tool, nullptr); + + // Invoke through composite + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + tool->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result.contains("items")); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/retry_test.cc b/third_party/gopher-orch/tests/gopher/orch/retry_test.cc new file mode 100644 index 00000000..ee07359a --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/retry_test.cc @@ -0,0 +1,85 @@ +// Unit tests for Retry resilience pattern + +#include "orch_test_fixture.h" + +// ============================================================================= +// Retry Tests +// ============================================================================= + +TEST_F(OrchTest, RetrySuccess) { + // Test that successful operation returns immediately + auto successLambda = makeJsonLambda( + [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["success"] = JsonValue(true); + return makeSuccess(JsonValue(result)); + }, + "SuccessLambda"); + + auto retryLambda = withRetry(successLambda, RetryPolicy::exponential(3)); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + retryLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_TRUE(result["success"].getBool()); +} + +TEST_F(OrchTest, RetryEventualSuccess) { + // Test that retry succeeds after failures + std::atomic attempt_count{0}; + + auto eventualSuccess = makeJsonLambda( + [&attempt_count](const JsonValue&) -> Result { + int attempt = ++attempt_count; + if (attempt < 3) { + return Result( + Error(OrchError::INTERNAL_ERROR, "Temporary failure")); + } + JsonValue result = JsonValue::object(); + result["attempt"] = JsonValue(attempt); + return makeSuccess(JsonValue(result)); + }, + "EventualSuccess"); + + // Use fixed delay policy for faster test + auto policy = RetryPolicy::fixed(5, 10); // 5 attempts, 10ms delay + auto retryLambda = withRetry(eventualSuccess, policy); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + retryLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_EQ(result["attempt"].getInt(), 3); + EXPECT_EQ(attempt_count.load(), 3); +} + +TEST_F(OrchTest, RetryExhausted) { + // Test that retry fails after max attempts + std::atomic attempt_count{0}; + + auto alwaysFails = makeJsonLambda( + [&attempt_count](const JsonValue&) -> Result { + attempt_count++; + return Result( + Error(OrchError::INTERNAL_ERROR, "Persistent failure")); + }, + "AlwaysFails"); + + auto policy = RetryPolicy::fixed(3, 10); // 3 attempts, 10ms delay + auto retryLambda = withRetry(alwaysFails, policy); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + retryLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "Persistent failure"); + EXPECT_EQ(attempt_count.load(), 3); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/router_test.cc b/third_party/gopher-orch/tests/gopher/orch/router_test.cc new file mode 100644 index 00000000..a7fbb128 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/router_test.cc @@ -0,0 +1,110 @@ +// Unit tests for Router composition pattern + +#include "orch_test_fixture.h" + +// ============================================================================= +// Router Tests +// ============================================================================= + +TEST_F(OrchTest, RouterBasic) { + // Create branches for different conditions + auto positiveHandler = makeJsonLambda( + [](const JsonValue& input) -> Result { + JsonValue result = JsonValue::object(); + result["type"] = JsonValue("positive"); + result["value"] = JsonValue(input["value"].getInt()); + return makeSuccess(JsonValue(result)); + }, + "PositiveHandler"); + + auto negativeHandler = makeJsonLambda( + [](const JsonValue& input) -> Result { + JsonValue result = JsonValue::object(); + result["type"] = JsonValue("negative"); + result["value"] = JsonValue(input["value"].getInt()); + return makeSuccess(JsonValue(result)); + }, + "NegativeHandler"); + + auto defaultHandler = makeJsonLambda( + [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["type"] = JsonValue("zero"); + return makeSuccess(JsonValue(result)); + }, + "DefaultHandler"); + + auto routerRunnable = router("NumberRouter") + .when( + [](const JsonValue& input) { + return input["value"].getInt() > 0; + }, + positiveHandler) + .when( + [](const JsonValue& input) { + return input["value"].getInt() < 0; + }, + negativeHandler) + .otherwise(defaultHandler) + .build(); + + // Test positive number + JsonValue positiveInput = JsonValue::object(); + positiveInput["value"] = JsonValue(42); + + JsonValue result1 = runToCompletion([&](Dispatcher& d, + JsonCallback cb) { + routerRunnable->invoke(positiveInput, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result1["type"].getString(), "positive"); + EXPECT_EQ(result1["value"].getInt(), 42); + + // Test negative number + JsonValue negativeInput = JsonValue::object(); + negativeInput["value"] = JsonValue(-10); + + JsonValue result2 = runToCompletion([&](Dispatcher& d, + JsonCallback cb) { + routerRunnable->invoke(negativeInput, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result2["type"].getString(), "negative"); + + // Test zero (default) + JsonValue zeroInput = JsonValue::object(); + zeroInput["value"] = JsonValue(0); + + JsonValue result3 = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + routerRunnable->invoke(zeroInput, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result3["type"].getString(), "zero"); +} + +TEST_F(OrchTest, RouterNoMatchNoDefault) { + // Router without default route should return error + auto handler = makeJsonLambda( + [](const JsonValue&) -> Result { + return makeSuccess(JsonValue::object()); + }, + "Handler"); + + auto routerRunnable = + router() + .when([](const JsonValue& input) { return input["match"].getBool(); }, + handler) + .build(); + + JsonValue input = JsonValue::object(); + input["match"] = JsonValue(false); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + routerRunnable->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::INVALID_ARGUMENT); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/sequence_test.cc b/third_party/gopher-orch/tests/gopher/orch/sequence_test.cc new file mode 100644 index 00000000..5f792473 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/sequence_test.cc @@ -0,0 +1,87 @@ +// Unit tests for Sequence composition pattern + +#include "orch_test_fixture.h" + +// ============================================================================= +// Sequence Tests +// ============================================================================= + +TEST_F(OrchTest, SequenceBasic) { + // Create two lambdas and chain them + auto step1 = makeJsonLambda( + [](const JsonValue& input) -> Result { + JsonValue result = JsonValue::object(); + result["step1"] = JsonValue(true); + result["value"] = JsonValue(input["value"].getInt() + 1); + return makeSuccess(JsonValue(result)); + }, + "Step1"); + + auto step2 = makeJsonLambda( + [](const JsonValue& input) -> Result { + JsonValue result = JsonValue::object(); + result["step2"] = JsonValue(true); + result["value"] = JsonValue(input["value"].getInt() * 2); + return makeSuccess(JsonValue(result)); + }, + "Step2"); + + auto seq = sequence("TestSequence").add(step1).add(step2).build(); + + EXPECT_EQ(seq->size(), 2u); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + JsonValue input = JsonValue::object(); + input["value"] = JsonValue(10); + seq->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // (10 + 1) * 2 = 22 + EXPECT_EQ(result["value"].getInt(), 22); + EXPECT_TRUE(result["step2"].getBool()); +} + +TEST_F(OrchTest, SequenceShortCircuit) { + std::atomic step2_called{0}; + + auto step1 = makeJsonLambda( + [](const JsonValue&) -> Result { + return Result( + Error(OrchError::INVALID_ARGUMENT, "Step1 failed")); + }, + "FailingStep"); + + auto step2 = makeJsonLambda( + [&step2_called](const JsonValue& input) -> Result { + step2_called++; + return makeSuccess(JsonValue(input)); + }, + "Step2"); + + auto seq = sequence().add(step1).add(step2).build(); + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + seq->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "Step1 failed"); + EXPECT_EQ(step2_called.load(), 0); // Step2 should not be called +} + +TEST_F(OrchTest, SequenceEmpty) { + auto seq = sequence().build(); + + JsonValue input = JsonValue::object(); + input["pass_through"] = JsonValue(true); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + seq->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // Empty sequence passes through input + EXPECT_TRUE(result["pass_through"].getBool()); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/server_composite_test.cc b/third_party/gopher-orch/tests/gopher/orch/server_composite_test.cc new file mode 100644 index 00000000..07095fcb --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/server_composite_test.cc @@ -0,0 +1,372 @@ +// Unit tests for ServerComposite +// +// Tests multi-server aggregation, tool namespacing, aliasing, +// and connection management across multiple mock servers. + +#include "orch_test_fixture.h" + +// ============================================================================= +// ServerComposite Tests +// ============================================================================= + +TEST_F(OrchTest, ServerCompositeCreate) { + auto composite = ServerComposite::create("test-composite"); + EXPECT_EQ(composite->name(), "test-composite"); + EXPECT_TRUE(composite->listTools().empty()); + EXPECT_TRUE(composite->servers().empty()); +} + +TEST_F(OrchTest, ServerCompositeAddServer) { + auto composite = ServerComposite::create("test-composite"); + + auto server1 = makeMockServer("server1"); + server1->addTool("tool1", "First tool"); + + auto server2 = makeMockServer("server2"); + server2->addTool("tool2", "Second tool"); + + // Add servers with explicit tool mappings + std::vector tools1 = {"tool1"}; + std::vector tools2 = {"tool2"}; + composite->addServer(server1, tools1, true); + composite->addServer(server2, tools2, true); + + EXPECT_EQ(composite->servers().size(), 2u); + EXPECT_NE(composite->server("server1"), nullptr); + EXPECT_NE(composite->server("server2"), nullptr); + EXPECT_EQ(composite->server("nonexistent"), nullptr); +} + +TEST_F(OrchTest, ServerCompositeToolNamespacing) { + // Tests that tools are namespaced by server name when namespace_tools=true + auto composite = ServerComposite::create("namespaced"); + + auto server = makeMockServer("weather"); + server->addTool("get_forecast", "Gets weather forecast"); + server->setResponse("get_forecast", JsonValue("Sunny")); + + std::vector tool_names = {"get_forecast"}; + composite->addServer(server, tool_names, true); + + // Tools should be listed with namespace prefix + auto tools = composite->listTools(); + EXPECT_EQ(tools.size(), 1u); + EXPECT_EQ(tools[0], "weather.get_forecast"); + + // Can get tool by fully-qualified name + EXPECT_TRUE(composite->hasTool("weather.get_forecast")); +} + +TEST_F(OrchTest, ServerCompositeNoNamespacing) { + // Tests that tools are exposed without prefix when namespace_tools=false + auto composite = ServerComposite::create("flat"); + + auto server = makeMockServer("myserver"); + server->addTool("simple_tool", "A simple tool"); + + std::vector tool_names = {"simple_tool"}; + composite->addServer(server, tool_names, false); + + auto tools = composite->listTools(); + EXPECT_EQ(tools.size(), 1u); + EXPECT_EQ(tools[0], "simple_tool"); + + EXPECT_TRUE(composite->hasTool("simple_tool")); +} + +TEST_F(OrchTest, ServerCompositeAliases) { + // Tests tool aliasing - expose tools under different names + auto composite = ServerComposite::create("aliased"); + + auto server = makeMockServer("complex-name-server"); + server->addTool("internal_get_data_v2", "Gets data"); + server->setResponse("internal_get_data_v2", JsonValue("data")); + + // Map internal name to a simpler alias + std::map aliases = { + {"get_data", "internal_get_data_v2"}, {"fetch", "internal_get_data_v2"} + // Multiple aliases for same tool + }; + composite->addServerWithAliases(server, aliases); + + auto tools = composite->listTools(); + EXPECT_EQ(tools.size(), 2u); + + EXPECT_TRUE(composite->hasTool("get_data")); + EXPECT_TRUE(composite->hasTool("fetch")); +} + +TEST_F(OrchTest, ServerCompositeAddSingleTool) { + // Tests adding individual tools with optional alias + auto composite = ServerComposite::create("single-tool"); + + auto server = makeMockServer("myserver"); + server->addTool("tool1", "Tool one"); + server->addTool("tool2", "Tool two"); + + // Add only one tool with an alias + composite->addTool(server, "tool1", "my_tool"); + + auto tools = composite->listTools(); + EXPECT_EQ(tools.size(), 1u); + EXPECT_EQ(tools[0], "my_tool"); + + EXPECT_TRUE(composite->hasTool("my_tool")); + EXPECT_FALSE(composite->hasTool("tool1")); + EXPECT_FALSE(composite->hasTool("tool2")); +} + +TEST_F(OrchTest, ServerCompositeRemoveServer) { + auto composite = ServerComposite::create("removable"); + + auto server1 = makeMockServer("server1"); + server1->addTool("tool1"); + std::vector t1 = {"tool1"}; + composite->addServer(server1, t1, true); + + auto server2 = makeMockServer("server2"); + server2->addTool("tool2"); + std::vector t2 = {"tool2"}; + composite->addServer(server2, t2, true); + + EXPECT_EQ(composite->servers().size(), 2u); + EXPECT_TRUE(composite->hasTool("server1.tool1")); + EXPECT_TRUE(composite->hasTool("server2.tool2")); + + // Remove server1 + composite->removeServer("server1"); + + EXPECT_EQ(composite->servers().size(), 1u); + EXPECT_FALSE(composite->hasTool("server1.tool1")); + EXPECT_TRUE(composite->hasTool("server2.tool2")); +} + +TEST_F(OrchTest, ServerCompositeConnectAll) { + // Tests connecting all servers at once + auto composite = ServerComposite::create("connect-all"); + + auto server1 = makeMockServer("server1"); + server1->addTool("tool1"); + composite->addServer(server1, std::vector{"tool1"}, true); + + auto server2 = makeMockServer("server2"); + server2->addTool("tool2"); + composite->addServer(server2, std::vector{"tool2"}, true); + + // Both servers should be disconnected initially + EXPECT_EQ(server1->connectionState(), ConnectionState::DISCONNECTED); + EXPECT_EQ(server2->connectionState(), ConnectionState::DISCONNECTED); + + // Connect all servers + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + composite->connectAll(d, std::move(cb)); + }); + + // Both servers should now be connected + EXPECT_TRUE(server1->isConnected()); + EXPECT_TRUE(server2->isConnected()); +} + +TEST_F(OrchTest, ServerCompositeConnectAllEmpty) { + // Tests connecting when no servers are added (should succeed immediately) + auto composite = ServerComposite::create("empty"); + + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + composite->connectAll(d, std::move(cb)); + }); + // Should complete without error +} + +TEST_F(OrchTest, ServerCompositeDisconnectAll) { + auto composite = ServerComposite::create("disconnect-all"); + + auto server1 = makeMockServer("server1"); + server1->addTool("tool1"); + std::vector t1 = {"tool1"}; + composite->addServer(server1, t1, true); + + auto server2 = makeMockServer("server2"); + server2->addTool("tool2"); + std::vector t2 = {"tool2"}; + composite->addServer(server2, t2, true); + + // Connect first + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + composite->connectAll(d, std::move(cb)); + }); + + EXPECT_TRUE(server1->isConnected()); + EXPECT_TRUE(server2->isConnected()); + + // Disconnect all + bool disconnected = false; + composite->disconnectAll(*dispatcher_, [&]() { disconnected = true; }); + + // Run dispatcher until callback fires + while (!disconnected) { + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + EXPECT_FALSE(server1->isConnected()); + EXPECT_FALSE(server2->isConnected()); +} + +TEST_F(OrchTest, ServerCompositeToolInvocation) { + // Tests invoking a tool through the composite + auto composite = ServerComposite::create("invoke-test"); + + auto server = makeMockServer("math"); + server->addTool("add", "Adds two numbers"); + server->setHandler("add", [](const JsonValue& args) -> Result { + int a = args["a"].getInt(); + int b = args["b"].getInt(); + JsonValue result = JsonValue::object(); + result["sum"] = JsonValue(a + b); + return makeSuccess(JsonValue(result)); + }); + + std::vector tool_names = {"add"}; + composite->addServer(server, tool_names, true); + + // Connect + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + composite->connectAll(d, std::move(cb)); + }); + + // Get tool through composite + auto addTool = composite->tool("math.add"); + EXPECT_NE(addTool, nullptr); + EXPECT_EQ(addTool->name(), "math.add"); + + // Invoke the tool + JsonValue input = JsonValue::object(); + input["a"] = JsonValue(3); + input["b"] = JsonValue(5); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + addTool->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["sum"].getInt(), 8); +} + +TEST_F(OrchTest, ServerCompositeToolByServerAndName) { + // Tests the two-argument tool() method + auto composite = ServerComposite::create("two-arg"); + + auto server = makeMockServer("myserver"); + server->addTool("mytool"); + server->setResponse("mytool", JsonValue("result")); + + std::vector tool_names = {"mytool"}; + composite->addServer(server, tool_names, true); + + // Get tool using server name and tool name + auto tool = composite->tool("myserver", "mytool"); + EXPECT_NE(tool, nullptr); +} + +TEST_F(OrchTest, ServerCompositeToolCaching) { + // Tests that tool objects are cached + auto composite = ServerComposite::create("cache-test"); + + auto server = makeMockServer("server"); + server->addTool("tool"); + std::vector tool_names = {"tool"}; + composite->addServer(server, tool_names, true); + + auto tool1 = composite->tool("server.tool"); + auto tool2 = composite->tool("server.tool"); + + // Should return the same cached object + EXPECT_EQ(tool1.get(), tool2.get()); +} + +TEST_F(OrchTest, ServerCompositeToolNotFound) { + auto composite = ServerComposite::create("not-found"); + + auto server = makeMockServer("server"); + server->addTool("existing_tool"); + std::vector tool_names = {"existing_tool"}; + composite->addServer(server, tool_names, true); + + // Try to get a non-existent tool by alias/direct name - returns nullptr + auto tool = composite->tool("nonexistent"); + EXPECT_EQ(tool, nullptr); + + EXPECT_FALSE(composite->hasTool("nonexistent")); + + // Note: Fully-qualified names (server.tool) can resolve to any tool on + // a registered server, even if not explicitly mapped. This allows dynamic + // tool discovery while still supporting explicit mappings for aliases. + EXPECT_TRUE(composite->hasTool("server.existing_tool")); // Mapped explicitly +} + +TEST_F(OrchTest, ServerCompositeMultipleToolsSameServer) { + // Tests adding multiple tools from the same server + auto composite = ServerComposite::create("multi-tool"); + + auto server = makeMockServer("api"); + server->addTool("read", "Reads data"); + server->addTool("write", "Writes data"); + server->addTool("delete", "Deletes data"); + + std::vector tool_names = {"read", "write", "delete"}; + composite->addServer(server, tool_names, true); + + auto tools = composite->listTools(); + EXPECT_EQ(tools.size(), 3u); + + EXPECT_TRUE(composite->hasTool("api.read")); + EXPECT_TRUE(composite->hasTool("api.write")); + EXPECT_TRUE(composite->hasTool("api.delete")); +} + +TEST_F(OrchTest, ServerCompositeListToolInfos) { + auto composite = ServerComposite::create("info-test"); + + auto server = makeMockServer("server"); + server->addTool("tool1", "Tool one description"); + server->addTool("tool2", "Tool two description"); + + std::vector tool_names = {"tool1", "tool2"}; + composite->addServer(server, tool_names, true); + + auto infos = composite->listToolInfos(); + EXPECT_EQ(infos.size(), 2u); + + // Check that exposed names are set + bool found_tool1 = false, found_tool2 = false; + for (const auto& info : infos) { + if (info.name == "server.tool1") + found_tool1 = true; + if (info.name == "server.tool2") + found_tool2 = true; + } + EXPECT_TRUE(found_tool1); + EXPECT_TRUE(found_tool2); +} + +TEST_F(OrchTest, ServerCompositeChainedAdditions) { + // Tests fluent API for adding servers and tools + auto composite = ServerComposite::create("chained"); + + auto server1 = makeMockServer("s1"); + server1->addTool("t1"); + auto server2 = makeMockServer("s2"); + server2->addTool("t2"); + + // Chain additions + std::vector t1 = {"t1"}; + std::vector t2 = {"t2"}; + composite->addServer(server1, t1, true).addServer(server2, t2, true); + + EXPECT_EQ(composite->servers().size(), 2u); + EXPECT_EQ(composite->listTools().size(), 2u); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/state_graph_test.cc b/third_party/gopher-orch/tests/gopher/orch/state_graph_test.cc new file mode 100644 index 00000000..be84ae3b --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/state_graph_test.cc @@ -0,0 +1,376 @@ +// Unit tests for StateGraph (stateful workflow graphs) + +#include "orch_test_fixture.h" + +using namespace gopher::orch::graph; + +// ============================================================================= +// StateGraph Tests +// ============================================================================= + +TEST_F(OrchTest, StateGraphBasic) { + // Create a simple linear graph: start -> process -> end + StateGraph graph; + graph + .addNode("start", + [](const GraphState& state) { + GraphState result = state; + result.set("step", JsonValue("started")); + return result; + }) + .addNode("process", + [](const GraphState& state) { + GraphState result = state; + result.set("step", JsonValue("processed")); + result.set("value", + JsonValue(state.get("input").getInt() * 2)); + return result; + }) + .addEdge("start", "process") + .addEdge("process", StateGraph::END()) + .setEntryPoint("start"); + + auto compiled = graph.compile(); + + JsonValue input = JsonValue::object(); + input["input"] = JsonValue(21); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + compiled->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["step"].getString(), "processed"); + EXPECT_EQ(result["value"].getInt(), 42); +} + +TEST_F(OrchTest, StateGraphConditionalEdge) { + // Create a graph with conditional branching + StateGraph graph; + graph + .addNode("check", + [](const GraphState& state) { + // Just pass through - condition is evaluated on edge + return state; + }) + .addNode("positive_path", + [](const GraphState& state) { + GraphState result = state; + result.set("path", JsonValue("positive")); + return result; + }) + .addNode("negative_path", + [](const GraphState& state) { + GraphState result = state; + result.set("path", JsonValue("negative")); + return result; + }) + .addConditionalEdge("check", + [](const GraphState& state) { + int value = state.get("value").getInt(); + if (value > 0) { + return std::string("positive_path"); + } else { + return std::string("negative_path"); + } + }) + .addEdge("positive_path", StateGraph::END()) + .addEdge("negative_path", StateGraph::END()) + .setEntryPoint("check"); + + auto compiled = graph.compile(); + + // Test positive path + JsonValue positiveInput = JsonValue::object(); + positiveInput["value"] = JsonValue(10); + + JsonValue result1 = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + compiled->invoke(positiveInput, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result1["path"].getString(), "positive"); + + // Test negative path + JsonValue negativeInput = JsonValue::object(); + negativeInput["value"] = JsonValue(-5); + + JsonValue result2 = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + compiled->invoke(negativeInput, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result2["path"].getString(), "negative"); +} + +TEST_F(OrchTest, StateGraphWithRunnable) { + // Create a graph using JsonRunnable nodes + auto doubler = makeJsonLambda( + [](const JsonValue& input) -> Result { + JsonValue result = JsonValue::object(); + result["doubled"] = JsonValue(input["value"].getInt() * 2); + return makeSuccess(result); + }, + "Doubler"); + + StateGraph graph; + graph.addNode("double", doubler) + .addEdge("double", StateGraph::END()) + .setEntryPoint("double"); + + auto compiled = graph.compile(); + + JsonValue input = JsonValue::object(); + input["value"] = JsonValue(21); + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + compiled->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["doubled"].getInt(), 42); + EXPECT_EQ(result["value"].getInt(), 21); // Original value preserved +} + +TEST_F(OrchTest, StateGraphNoEntryPoint) { + StateGraph graph; + graph.addNode("node", [](const GraphState& state) { return state; }); + + auto compiled = graph.compile(); + + auto result = runToCompletionResult([&](Dispatcher& d, + JsonCallback cb) { + compiled->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::INVALID_ARGUMENT); +} + +TEST_F(OrchTest, StateGraphNodeNotFound) { + StateGraph graph; + graph.setEntryPoint("nonexistent"); + + auto compiled = graph.compile(); + + auto result = runToCompletionResult([&](Dispatcher& d, + JsonCallback cb) { + compiled->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::INVALID_ARGUMENT); +} + +TEST_F(OrchTest, GraphStateOperations) { + GraphState state; + + // Test set/get + state.set("key1", JsonValue("value1")); + state.set("key2", JsonValue(42)); + + EXPECT_TRUE(state.has("key1")); + EXPECT_TRUE(state.has("key2")); + EXPECT_FALSE(state.has("key3")); + + EXPECT_EQ(state.get("key1").getString(), "value1"); + EXPECT_EQ(state.get("key2").getInt(), 42); + EXPECT_TRUE(state.get("key3").isNull()); + + // Test version tracking + EXPECT_EQ(state.version("key1"), 1u); + state.set("key1", JsonValue("updated")); + EXPECT_EQ(state.version("key1"), 2u); + + // Test JSON serialization + JsonValue json = state.toJson(); + EXPECT_EQ(json["key1"].getString(), "updated"); + EXPECT_EQ(json["key2"].getInt(), 42); + + // Test fromJson + GraphState restored = GraphState::fromJson(json); + EXPECT_EQ(restored.get("key1").getString(), "updated"); + EXPECT_EQ(restored.get("key2").getInt(), 42); +} + +// ============================================================================= +// GraphState Channel/Reducer Tests +// ============================================================================= + +TEST_F(OrchTest, GraphStateWithReducerAppendArray) { + GraphState state; + + // Configure channel with array append reducer + state.configureChannel("messages", reducers::appendArray); + + // First message + JsonValue msg1 = JsonValue::array(); + msg1.push_back(JsonValue("hello")); + state.set("messages", msg1); + EXPECT_EQ(state.get("messages").size(), 1u); + EXPECT_EQ(state.get("messages")[0].getString(), "hello"); + + // Second message should be appended + JsonValue msg2 = JsonValue::array(); + msg2.push_back(JsonValue("world")); + state.set("messages", msg2); + EXPECT_EQ(state.get("messages").size(), 2u); + EXPECT_EQ(state.get("messages")[0].getString(), "hello"); + EXPECT_EQ(state.get("messages")[1].getString(), "world"); + + // Third message + JsonValue msg3 = JsonValue::array(); + msg3.push_back(JsonValue("!")); + state.set("messages", msg3); + EXPECT_EQ(state.get("messages").size(), 3u); +} + +TEST_F(OrchTest, GraphStateWithReducerMergeObjects) { + GraphState state; + + // Configure channel with object merge reducer + state.configureChannel("data", reducers::mergeObjects); + + // First object + JsonValue obj1 = JsonValue::object(); + obj1["a"] = JsonValue(1); + state.set("data", obj1); + EXPECT_EQ(state.get("data")["a"].getInt(), 1); + + // Second object should be merged + JsonValue obj2 = JsonValue::object(); + obj2["b"] = JsonValue(2); + state.set("data", obj2); + EXPECT_EQ(state.get("data")["a"].getInt(), 1); // preserved + EXPECT_EQ(state.get("data")["b"].getInt(), 2); // added + + // Third object should overwrite existing key + JsonValue obj3 = JsonValue::object(); + obj3["a"] = JsonValue(10); + obj3["c"] = JsonValue(3); + state.set("data", obj3); + EXPECT_EQ(state.get("data")["a"].getInt(), 10); // overwritten + EXPECT_EQ(state.get("data")["b"].getInt(), 2); // preserved + EXPECT_EQ(state.get("data")["c"].getInt(), 3); // added +} + +TEST_F(OrchTest, GraphStateWithCustomReducer) { + GraphState state; + + // Configure channel with custom max reducer + state.configureChannel( + "max_score", [](const JsonValue& old_val, const JsonValue& new_val) { + int old_score = old_val.getInt(); + int new_score = new_val.getInt(); + return JsonValue(std::max(old_score, new_score)); + }); + + state.set("max_score", JsonValue(10)); + EXPECT_EQ(state.get("max_score").getInt(), 10); + + state.set("max_score", JsonValue(5)); // Lower, should not change + EXPECT_EQ(state.get("max_score").getInt(), 10); + + state.set("max_score", JsonValue(20)); // Higher, should update + EXPECT_EQ(state.get("max_score").getInt(), 20); +} + +TEST_F(OrchTest, GraphStateMergeWithReducers) { + GraphState state1; + state1.configureChannel("items", reducers::appendArray); + + JsonValue items1 = JsonValue::array(); + items1.push_back(JsonValue(1)); + items1.push_back(JsonValue(2)); + state1.set("items", items1); + + GraphState state2; + JsonValue items2 = JsonValue::array(); + items2.push_back(JsonValue(3)); + state2.set("items", items2); + + // Merge should use reducer from state1 + state1.merge(state2); + EXPECT_EQ(state1.get("items").size(), 3u); + EXPECT_EQ(state1.get("items")[0].getInt(), 1); + EXPECT_EQ(state1.get("items")[1].getInt(), 2); + EXPECT_EQ(state1.get("items")[2].getInt(), 3); +} + +TEST_F(OrchTest, StateChannelTemplate) { + // Test the template version of StateChannel + StateChannel counter; + EXPECT_FALSE(counter.hasValue()); + EXPECT_EQ(counter.version(), 0u); + + counter.update(10); + EXPECT_TRUE(counter.hasValue()); + EXPECT_EQ(counter.value(), 10); + EXPECT_EQ(counter.version(), 1u); + + counter.update(20); + EXPECT_EQ(counter.value(), 20); // Last write wins (no reducer) + EXPECT_EQ(counter.version(), 2u); +} + +TEST_F(OrchTest, StateChannelWithReducer) { + // Test StateChannel with a custom reducer (sum) + StateChannel sum([](const int& a, const int& b) { return a + b; }); + + sum.update(10); + EXPECT_EQ(sum.value(), 10); + + sum.update(5); + EXPECT_EQ(sum.value(), 15); // 10 + 5 + + sum.update(3); + EXPECT_EQ(sum.value(), 18); // 15 + 3 +} + +TEST_F(OrchTest, GraphStateCopy) { + GraphState original; + original.configureChannel("data", reducers::appendArray); + + JsonValue arr = JsonValue::array(); + arr.push_back(JsonValue(1)); + original.set("data", arr); + + // Copy should preserve reducer configuration + GraphState copied = original.copy(); + + JsonValue arr2 = JsonValue::array(); + arr2.push_back(JsonValue(2)); + copied.set("data", arr2); + + // Original should be unchanged + EXPECT_EQ(original.get("data").size(), 1u); + + // Copied should have appended (reducer preserved) + EXPECT_EQ(copied.get("data").size(), 2u); +} + +TEST_F(OrchTest, GraphStateKeys) { + GraphState state; + state.set("alpha", JsonValue(1)); + state.set("beta", JsonValue(2)); + state.set("gamma", JsonValue(3)); + + auto keys = state.keys(); + EXPECT_EQ(keys.size(), 3u); + + // Keys should be sorted (std::map order) + EXPECT_EQ(keys[0], "alpha"); + EXPECT_EQ(keys[1], "beta"); + EXPECT_EQ(keys[2], "gamma"); +} + +TEST_F(OrchTest, StateGraphSTARTConstant) { + // Verify START() constant exists and is different from END() + EXPECT_EQ(StateGraph::START(), "__start__"); + EXPECT_EQ(StateGraph::END(), "__end__"); + EXPECT_NE(StateGraph::START(), StateGraph::END()); + + // Also verify on CompiledStateGraph + EXPECT_EQ(CompiledStateGraph::START(), "__start__"); + EXPECT_EQ(CompiledStateGraph::END(), "__end__"); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/state_machine_test.cc b/third_party/gopher-orch/tests/gopher/orch/state_machine_test.cc new file mode 100644 index 00000000..a69357d1 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/state_machine_test.cc @@ -0,0 +1,226 @@ +// Unit tests for StateMachine (finite state machine) + +#include "orch_test_fixture.h" + +using namespace gopher::orch::fsm; + +// Define test states and events (prefixed with Test to avoid conflict with +// server::TestConnState) +enum class TestConnState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }; +enum class TestConnEvent { CONNECT, CONNECTED, DISCONNECT, FAIL }; + +// ============================================================================= +// StateMachine Tests +// ============================================================================= + +TEST_F(OrchTest, StateMachineBasic) { + // Create a simple connection state machine + StateMachine sm(TestConnState::DISCONNECTED); + + sm.addTransition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING) + .addTransition(TestConnState::CONNECTING, TestConnEvent::CONNECTED, + TestConnState::CONNECTED) + .addTransition(TestConnState::CONNECTED, TestConnEvent::DISCONNECT, + TestConnState::DISCONNECTED) + .addTransition(TestConnState::CONNECTING, TestConnEvent::FAIL, + TestConnState::ERROR) + .addTransition(TestConnState::ERROR, TestConnEvent::CONNECT, + TestConnState::CONNECTING); + + EXPECT_EQ(sm.currentState(), TestConnState::DISCONNECTED); + + // Trigger transitions + auto result1 = sm.trigger(TestConnEvent::CONNECT); + EXPECT_TRUE(mcp::holds_alternative(result1)); + EXPECT_EQ(sm.currentState(), TestConnState::CONNECTING); + + auto result2 = sm.trigger(TestConnEvent::CONNECTED); + EXPECT_TRUE(mcp::holds_alternative(result2)); + EXPECT_EQ(sm.currentState(), TestConnState::CONNECTED); + + auto result3 = sm.trigger(TestConnEvent::DISCONNECT); + EXPECT_TRUE(mcp::holds_alternative(result3)); + EXPECT_EQ(sm.currentState(), TestConnState::DISCONNECTED); +} + +TEST_F(OrchTest, StateMachineInvalidTransition) { + StateMachine sm(TestConnState::DISCONNECTED); + + sm.addTransition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING); + + // Try invalid transition + auto result = sm.trigger(TestConnEvent::DISCONNECT); + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::INVALID_TRANSITION); + EXPECT_EQ(sm.currentState(), TestConnState::DISCONNECTED); +} + +TEST_F(OrchTest, StateMachineWithGuard) { + // Use int as context to track retry count + StateMachine sm( + TestConnState::DISCONNECTED); + + sm.addTransition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING) + .addTransition(TestConnState::CONNECTING, TestConnEvent::FAIL, + TestConnState::DISCONNECTED) + .setGuard(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + [](TestConnState, TestConnEvent, const int& retries) { + // Only allow connect if retries < 3 + return retries < 3; + }); + + sm.setContext(0); + + // First connect should work + auto result1 = sm.trigger(TestConnEvent::CONNECT); + EXPECT_TRUE(mcp::holds_alternative(result1)); + EXPECT_EQ(sm.currentState(), TestConnState::CONNECTING); + + // Fail and increment retry count + sm.trigger(TestConnEvent::FAIL); + sm.setContext(1); + + // Second connect should work + auto result2 = sm.trigger(TestConnEvent::CONNECT); + EXPECT_TRUE(mcp::holds_alternative(result2)); + + sm.trigger(TestConnEvent::FAIL); + sm.setContext(3); // Set to 3 retries + + // Third connect should be rejected by guard + auto result3 = sm.trigger(TestConnEvent::CONNECT); + EXPECT_TRUE(mcp::holds_alternative(result3)); + EXPECT_EQ(mcp::get(result3).code, OrchError::GUARD_REJECTED); +} + +TEST_F(OrchTest, StateMachineWithCallbacks) { + std::vector log; + + StateMachine sm(TestConnState::DISCONNECTED); + + sm.addTransition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING) + .addTransition(TestConnState::CONNECTING, TestConnEvent::CONNECTED, + TestConnState::CONNECTED) + .onEnter( + TestConnState::CONNECTING, + [&log](TestConnState, void*&) { log.push_back("enter_connecting"); }) + .onExit( + TestConnState::CONNECTING, + [&log](TestConnState, void*&) { log.push_back("exit_connecting"); }) + .onEnter( + TestConnState::CONNECTED, + [&log](TestConnState, void*&) { log.push_back("enter_connected"); }) + .onStateChange([&log](TestConnState from, TestConnState to, + TestConnEvent) { log.push_back("state_change"); }); + + sm.trigger(TestConnEvent::CONNECT); + sm.trigger(TestConnEvent::CONNECTED); + + EXPECT_EQ(log.size(), 5u); + EXPECT_EQ(log[0], "enter_connecting"); + EXPECT_EQ(log[1], "state_change"); + EXPECT_EQ(log[2], "exit_connecting"); + EXPECT_EQ(log[3], "enter_connected"); + EXPECT_EQ(log[4], "state_change"); +} + +TEST_F(OrchTest, StateMachineValidEvents) { + StateMachine sm(TestConnState::DISCONNECTED); + + sm.addTransition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING) + .addTransition(TestConnState::CONNECTING, TestConnEvent::CONNECTED, + TestConnState::CONNECTED) + .addTransition(TestConnState::CONNECTING, TestConnEvent::FAIL, + TestConnState::ERROR); + + // From DISCONNECTED, only CONNECT is valid + auto events = sm.validEvents(); + EXPECT_EQ(events.size(), 1u); + EXPECT_EQ(events[0], TestConnEvent::CONNECT); + + // Move to CONNECTING + sm.trigger(TestConnEvent::CONNECT); + + // From CONNECTING, CONNECTED and FAIL are valid + events = sm.validEvents(); + EXPECT_EQ(events.size(), 2u); +} + +TEST_F(OrchTest, StateMachineCanTrigger) { + StateMachine sm(TestConnState::DISCONNECTED); + + sm.addTransition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING); + + EXPECT_TRUE(sm.canTrigger(TestConnEvent::CONNECT)); + EXPECT_FALSE(sm.canTrigger(TestConnEvent::DISCONNECT)); + EXPECT_FALSE(sm.canTrigger(TestConnEvent::CONNECTED)); +} + +TEST_F(OrchTest, StateMachineBuilder) { + auto sm = makeStateMachine( + TestConnState::DISCONNECTED) + .transition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING) + .transition(TestConnState::CONNECTING, TestConnEvent::CONNECTED, + TestConnState::CONNECTED) + .build(); + + EXPECT_EQ(sm->currentState(), TestConnState::DISCONNECTED); + + sm->trigger(TestConnEvent::CONNECT); + EXPECT_EQ(sm->currentState(), TestConnState::CONNECTING); + + sm->trigger(TestConnEvent::CONNECTED); + EXPECT_EQ(sm->currentState(), TestConnState::CONNECTED); +} + +TEST_F(OrchTest, StateMachineReset) { + StateMachine sm(TestConnState::CONNECTED); + + EXPECT_EQ(sm.currentState(), TestConnState::CONNECTED); + + sm.reset(TestConnState::DISCONNECTED); + EXPECT_EQ(sm.currentState(), TestConnState::DISCONNECTED); +} + +TEST_F(OrchTest, StateMachineAsyncTrigger) { + StateMachine sm(TestConnState::DISCONNECTED); + + sm.addTransition(TestConnState::DISCONNECTED, TestConnEvent::CONNECT, + TestConnState::CONNECTING); + + std::mutex mutex; + std::condition_variable cv; + bool done = false; + Result async_result = + Result(Error(-1, "Not completed")); + + sm.triggerAsync(TestConnEvent::CONNECT, *dispatcher_, + [&](Result result) { + std::lock_guard lock(mutex); + async_result = std::move(result); + done = true; + cv.notify_one(); + }); + + // Run dispatcher until done + while (true) { + { + std::unique_lock lock(mutex); + if (done) + break; + } + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + EXPECT_TRUE(mcp::holds_alternative(async_result)); + EXPECT_EQ(mcp::get(async_result), TestConnState::CONNECTING); + EXPECT_EQ(sm.currentState(), TestConnState::CONNECTING); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/timeout_test.cc b/third_party/gopher-orch/tests/gopher/orch/timeout_test.cc new file mode 100644 index 00000000..382c67be --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/timeout_test.cc @@ -0,0 +1,64 @@ +// Unit tests for Timeout resilience pattern + +#include "orch_test_fixture.h" + +// ============================================================================= +// Timeout Tests +// ============================================================================= + +TEST_F(OrchTest, TimeoutSuccess) { + // Operation completes before timeout + auto fastLambda = makeJsonLambda( + [](const JsonValue&) -> Result { + JsonValue result = JsonValue::object(); + result["completed"] = JsonValue(true); + return makeSuccess(JsonValue(result)); + }, + "FastLambda"); + + auto timeoutLambda = withTimeout(fastLambda, 1000); // 1 second timeout + + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + timeoutLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_TRUE(result["completed"].getBool()); +} + +TEST_F(OrchTest, TimeoutExpired) { + // Operation takes longer than timeout + // Use shared_ptr to keep timer alive until it fires + struct TimerHolder { + mcp::event::TimerPtr timer; + }; + + auto slowLambda = makeLambdaAsync( + [](const JsonValue&, const RunnableConfig&, Dispatcher& dispatcher, + JsonCallback callback) { + // Create holder to keep timer alive + auto holder = std::make_shared(); + + // Schedule completion after 500ms - but timeout is 50ms + holder->timer = dispatcher.createTimer( + [callback = std::move(callback), holder]() mutable { + JsonValue result = JsonValue::object(); + result["completed"] = JsonValue(true); + callback(makeSuccess(JsonValue(result))); + }); + holder->timer->enableTimer(std::chrono::milliseconds(500)); + }, + "SlowLambda"); + + auto timeoutLambda = withTimeout(slowLambda, 50); // 50ms timeout + + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + timeoutLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).code, OrchError::TIMEOUT); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/tool_registry_test.cc b/third_party/gopher-orch/tests/gopher/orch/tool_registry_test.cc new file mode 100644 index 00000000..a6b29da2 --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/tool_registry_test.cc @@ -0,0 +1,766 @@ +// Unit tests for ToolRegistry and ToolExecutor + +#include "gopher/orch/agent/tool_registry.h" + +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/agent/tool_definition.h" +#include "gopher/orch/agent/tool_executor.h" +#include "gopher/orch/server/mock_server.h" +#include "orch_test_fixture.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; +using namespace gopher::orch::server; + +// ============================================================================= +// ToolRegistry Test Fixture +// ============================================================================= + +class ToolRegistryTest : public OrchTest { + protected: + ToolRegistryPtr registry_; + ToolExecutorPtr executor_; + std::shared_ptr mock_server_; + + void SetUp() override { + OrchTest::SetUp(); + registry_ = makeToolRegistry(); + executor_ = makeToolExecutor(registry_); + mock_server_ = makeMockServer("test-server"); + } + + // Helper to build a simple JSON schema + JsonValue makeSchema(const std::string& type = "object") { + JsonValue schema = JsonValue::object(); + schema["type"] = type; + return schema; + } + + // Helper to build a schema with properties + JsonValue makeSchemaWithProps( + const std::map& props) { + JsonValue schema = JsonValue::object(); + schema["type"] = "object"; + + JsonValue properties = JsonValue::object(); + for (const auto& kv : props) { + JsonValue prop = JsonValue::object(); + prop["type"] = kv.second; + properties[kv.first] = prop; + } + schema["properties"] = properties; + + return schema; + } +}; + +// ============================================================================= +// Basic Tool Registration Tests +// ============================================================================= + +TEST_F(ToolRegistryTest, CreateEmpty) { + EXPECT_EQ(registry_->toolCount(), 0u); + EXPECT_TRUE(registry_->getToolSpecs().empty()); + EXPECT_TRUE(registry_->getToolNames().empty()); +} + +TEST_F(ToolRegistryTest, AddLocalTool) { + registry_->addTool("calculator", "Perform calculations", makeSchema(), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + cb(Result(JsonValue(42))); + }); + + EXPECT_EQ(registry_->toolCount(), 1u); + EXPECT_TRUE(registry_->hasTool("calculator")); + EXPECT_FALSE(registry_->hasTool("nonexistent")); + + auto specs = registry_->getToolSpecs(); + ASSERT_EQ(specs.size(), 1u); + EXPECT_EQ(specs[0].name, "calculator"); + EXPECT_EQ(specs[0].description, "Perform calculations"); +} + +TEST_F(ToolRegistryTest, AddToolWithSpec) { + ToolSpec spec("search", "Search the web", makeSchema()); + registry_->addTool(spec, + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + cb(Result(JsonValue("search result"))); + }); + + EXPECT_TRUE(registry_->hasTool("search")); + + auto retrieved = registry_->getToolSpec("search"); + ASSERT_TRUE(retrieved.has_value()); + EXPECT_EQ(retrieved->name, "search"); + EXPECT_EQ(retrieved->description, "Search the web"); +} + +TEST_F(ToolRegistryTest, AddSyncTool) { + registry_->addSyncTool("sync_calc", "Synchronous calculation", makeSchema(), + [](const JsonValue& args) -> Result { + return Result(JsonValue(100)); + }); + + EXPECT_TRUE(registry_->hasTool("sync_calc")); + + auto result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("sync_calc", JsonValue::object(), d, std::move(cb)); + }); + + EXPECT_EQ(result.getInt(), 100); +} + +TEST_F(ToolRegistryTest, AddMultipleTools) { + registry_->addTool("tool1", "Tool 1", makeSchema(), + [](const JsonValue&, Dispatcher&, JsonCallback cb) { + cb(Result(JsonValue(1))); + }); + + registry_->addTool("tool2", "Tool 2", makeSchema(), + [](const JsonValue&, Dispatcher&, JsonCallback cb) { + cb(Result(JsonValue(2))); + }); + + registry_->addTool("tool3", "Tool 3", makeSchema(), + [](const JsonValue&, Dispatcher&, JsonCallback cb) { + cb(Result(JsonValue(3))); + }); + + EXPECT_EQ(registry_->toolCount(), 3u); + + auto names = registry_->getToolNames(); + EXPECT_EQ(names.size(), 3u); + EXPECT_TRUE(std::find(names.begin(), names.end(), "tool1") != names.end()); + EXPECT_TRUE(std::find(names.begin(), names.end(), "tool2") != names.end()); + EXPECT_TRUE(std::find(names.begin(), names.end(), "tool3") != names.end()); +} + +// ============================================================================= +// Tool Execution Tests (via ToolExecutor) +// ============================================================================= + +TEST_F(ToolRegistryTest, ExecuteLocalTool) { + registry_->addTool("echo", "Echo input", makeSchema(), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + JsonValue result = JsonValue::object(); + result["echoed"] = args; + cb(Result(std::move(result))); + }); + + JsonValue input = JsonValue::object(); + input["message"] = "hello"; + + auto result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("echo", input, d, std::move(cb)); + }); + + EXPECT_TRUE(result.contains("echoed")); + EXPECT_EQ(result["echoed"]["message"].getString(), "hello"); +} + +TEST_F(ToolRegistryTest, ExecuteToolNotFound) { + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("nonexistent", JsonValue::object(), d, + std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + auto error = mcp::get(result); + EXPECT_TRUE(error.message.find("not found") != std::string::npos); +} + +TEST_F(ToolRegistryTest, ExecuteToolWithError) { + registry_->addSyncTool( + "failing", "Always fails", makeSchema(), + [](const JsonValue&) -> Result { + return Result(Error(-1, "Intentional failure")); + }); + + auto result = runToCompletionResult([&](Dispatcher& d, + JsonCallback cb) { + executor_->executeTool("failing", JsonValue::object(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "Intentional failure"); +} + +TEST_F(ToolRegistryTest, ExecuteToolCall) { + registry_->addSyncTool("greet", "Greet someone", makeSchema(), + [](const JsonValue& args) -> Result { + std::string name = args.contains("name") + ? args["name"].getString() + : "World"; + JsonValue result = JsonValue::object(); + result["greeting"] = "Hello, " + name + "!"; + return Result(result); + }); + + JsonValue args = JsonValue::object(); + args["name"] = "Alice"; + + ToolCall call("call_123", "greet", args); + + auto result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + executor_->executeToolCall(call, d, std::move(cb)); + }); + + EXPECT_EQ(result["greeting"].getString(), "Hello, Alice!"); +} + +TEST_F(ToolRegistryTest, ExecuteMultipleToolCalls) { + registry_->addSyncTool("double", "Double a number", makeSchema(), + [](const JsonValue& args) -> Result { + int n = args.contains("n") ? args["n"].getInt() : 0; + return Result(JsonValue(n * 2)); + }); + + registry_->addSyncTool("triple", "Triple a number", makeSchema(), + [](const JsonValue& args) -> Result { + int n = args.contains("n") ? args["n"].getInt() : 0; + return Result(JsonValue(n * 3)); + }); + + JsonValue args1 = JsonValue::object(); + args1["n"] = 5; + JsonValue args2 = JsonValue::object(); + args2["n"] = 10; + + std::vector calls = {ToolCall("call_1", "double", args1), + ToolCall("call_2", "triple", args2)}; + + std::vector> results; + + std::mutex mutex; + std::condition_variable cv; + bool done = false; + + executor_->executeToolCalls(calls, true, *dispatcher_, + [&](std::vector> r) { + std::lock_guard lock(mutex); + results = std::move(r); + done = true; + cv.notify_one(); + }); + + while (true) { + { + std::unique_lock lock(mutex); + if (done) + break; + } + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + ASSERT_EQ(results.size(), 2u); + EXPECT_TRUE(mcp::holds_alternative(results[0])); + EXPECT_TRUE(mcp::holds_alternative(results[1])); + EXPECT_EQ(mcp::get(results[0]).getInt(), 10); // 5 * 2 + EXPECT_EQ(mcp::get(results[1]).getInt(), 30); // 10 * 3 +} + +// ============================================================================= +// Server Integration Tests +// ============================================================================= + +TEST_F(ToolRegistryTest, AddServerWithToolList) { + // Add tools to mock server + mock_server_->addTool("server_tool1", "Server tool 1"); + mock_server_->addTool("server_tool2", "Server tool 2"); + mock_server_->setResponse("server_tool1", JsonValue("result1")); + mock_server_->setResponse("server_tool2", JsonValue("result2")); + + // Connect server + mock_server_->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + // Get tool list from server + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { + mock_server_->listTools(d, std::move(cb)); + }); + + // Add server with tools + registry_->addServer(mock_server_, tools); + + EXPECT_TRUE(registry_->hasTool("server_tool1")); + EXPECT_TRUE(registry_->hasTool("server_tool2")); + + // Check prefixed names also work + EXPECT_TRUE(registry_->hasTool("test-server:server_tool1")); +} + +TEST_F(ToolRegistryTest, ExecuteServerTool) { + mock_server_->addTool("remote_calc", "Remote calculation"); + + JsonValue calc_result = JsonValue::object(); + calc_result["answer"] = 42; + mock_server_->setResponse("remote_calc", calc_result); + + mock_server_->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { + mock_server_->listTools(d, std::move(cb)); + }); + + registry_->addServer(mock_server_, tools); + + auto result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("remote_calc", JsonValue::object(), d, + std::move(cb)); + }); + + EXPECT_EQ(result["answer"].getInt(), 42); + EXPECT_EQ(mock_server_->callCount("remote_calc"), 1u); +} + +TEST_F(ToolRegistryTest, AddServerToolWithAlias) { + mock_server_->addTool("original_name", "Original tool"); + mock_server_->setResponse("original_name", JsonValue("ok")); + + mock_server_->connect(*dispatcher_, [](Result) {}); + dispatcher_->run(mcp::event::RunType::NonBlock); + + ServerToolInfo info("original_name", "Original tool"); + registry_->addServerTool(mock_server_, info, "aliased_name"); + + EXPECT_TRUE(registry_->hasTool("aliased_name")); + EXPECT_FALSE(registry_->hasTool("original_name")); + + // Execute via alias + auto result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("aliased_name", JsonValue::object(), d, + std::move(cb)); + }); + + EXPECT_EQ(result.getString(), "ok"); +} + +// ============================================================================= +// Tool Management Tests +// ============================================================================= + +TEST_F(ToolRegistryTest, RemoveTool) { + registry_->addSyncTool("temp_tool", "Temporary", makeSchema(), + [](const JsonValue&) -> Result { + return Result(JsonValue("temp")); + }); + + EXPECT_TRUE(registry_->hasTool("temp_tool")); + EXPECT_EQ(registry_->toolCount(), 1u); + + registry_->removeTool("temp_tool"); + + EXPECT_FALSE(registry_->hasTool("temp_tool")); + EXPECT_EQ(registry_->toolCount(), 0u); +} + +TEST_F(ToolRegistryTest, Clear) { + registry_->addTool("tool1", "Tool 1", makeSchema(), + [](const JsonValue&, Dispatcher&, JsonCallback cb) { + cb(Result(JsonValue(1))); + }); + registry_->addTool("tool2", "Tool 2", makeSchema(), + [](const JsonValue&, Dispatcher&, JsonCallback cb) { + cb(Result(JsonValue(2))); + }); + + EXPECT_EQ(registry_->toolCount(), 2u); + + registry_->clear(); + + EXPECT_EQ(registry_->toolCount(), 0u); + EXPECT_TRUE(registry_->getToolSpecs().empty()); +} + +TEST_F(ToolRegistryTest, GetToolEntry) { + registry_->addTool("local_tool", "Local", makeSchema(), + [](const JsonValue&, Dispatcher&, JsonCallback cb) { + cb(Result(JsonValue("local"))); + }); + + auto entry = registry_->getToolEntry("local_tool"); + ASSERT_TRUE(entry.has_value()); + EXPECT_EQ(entry->spec.name, "local_tool"); + EXPECT_TRUE(entry->isLocal()); + EXPECT_FALSE(entry->isRemote()); + EXPECT_EQ(entry->server, nullptr); + + auto missing = registry_->getToolEntry("nonexistent"); + EXPECT_FALSE(missing.has_value()); +} + +// ============================================================================= +// Conversion Utility Tests +// ============================================================================= + +TEST(ToolConversionTest, ServerToolInfoToToolSpec) { + ServerToolInfo info; + info.name = "test_tool"; + info.description = "Test description"; + info.inputSchema = JsonValue::object(); + info.inputSchema["type"] = "object"; + + ToolSpec spec = toToolSpec(info); + + EXPECT_EQ(spec.name, "test_tool"); + EXPECT_EQ(spec.description, "Test description"); + EXPECT_TRUE(spec.parameters.contains("type")); +} + +TEST(ToolConversionTest, ToolSpecToServerToolInfo) { + ToolSpec spec; + spec.name = "another_tool"; + spec.description = "Another description"; + spec.parameters = JsonValue::object(); + spec.parameters["type"] = "object"; + + ServerToolInfo info = toServerToolInfo(spec); + + EXPECT_EQ(info.name, "another_tool"); + EXPECT_EQ(info.description, "Another description"); + EXPECT_TRUE(info.inputSchema.contains("type")); +} + +// ============================================================================= +// Environment Variable Tests +// ============================================================================= + +TEST_F(ToolRegistryTest, SetEnvVariable) { + registry_->setEnv("API_KEY", "secret123"); + registry_->setEnv("BASE_URL", "https://api.example.com"); + + // Env vars are used during config loading + // This test just verifies they can be set without errors + SUCCEED(); +} + +// ============================================================================= +// ConfigLoader Tests +// ============================================================================= + +class ConfigLoaderTest : public OrchTest { + protected: + ConfigLoader loader_; + + void SetUp() override { + OrchTest::SetUp(); + loader_.setEnv("API_KEY", "test-key-123"); + loader_.setEnv("BASE_URL", "https://api.test.com"); + } +}; + +TEST_F(ConfigLoaderTest, SubstituteEnvVars) { + std::string input = "Key: ${API_KEY}, URL: ${BASE_URL}"; + std::string result = loader_.substituteEnvVars(input); + + EXPECT_EQ(result, "Key: test-key-123, URL: https://api.test.com"); +} + +TEST_F(ConfigLoaderTest, SubstituteUnknownVar) { + std::string input = "Unknown: ${UNKNOWN_VAR}"; + std::string result = loader_.substituteEnvVars(input); + + // Unknown variables are replaced with empty string + EXPECT_EQ(result, "Unknown: "); +} + +TEST_F(ConfigLoaderTest, ParseHttpMethod) { + // Access private method via public API + // We test this indirectly through tool definition parsing + std::string json = R"({ + "name": "test_tool", + "description": "Test", + "rest_endpoint": { + "method": "POST", + "url": "https://api.test.com/endpoint" + } + })"; + + auto result = loader_.loadFromString("{\"tools\": [" + json + "]}"); + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto config = mcp::get(result); + ASSERT_EQ(config.tools.size(), 1u); + ASSERT_TRUE(config.tools[0].rest_endpoint.has_value()); + EXPECT_EQ(config.tools[0].rest_endpoint->method, HttpMethod::POST); +} + +TEST_F(ConfigLoaderTest, ParseToolDefinition) { + std::string json = R"({ + "name": "search", + "description": "Search the web", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string"} + } + }, + "tags": ["search", "web"], + "require_approval": true + })"; + + auto result = loader_.parseToolDefinition(JsonValue::parse(json)); + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto def = mcp::get(result); + EXPECT_EQ(def.name, "search"); + EXPECT_EQ(def.description, "Search the web"); + EXPECT_EQ(def.tags.size(), 2u); + EXPECT_TRUE(def.require_approval); + EXPECT_TRUE(def.input_schema.contains("properties")); +} + +TEST_F(ConfigLoaderTest, ParseToolDefinitionMissingName) { + std::string json = R"({ + "description": "No name provided" + })"; + + auto result = loader_.parseToolDefinition(JsonValue::parse(json)); + EXPECT_TRUE(mcp::holds_alternative(result)); +} + +TEST_F(ConfigLoaderTest, ParseMCPServerDefinition) { + std::string json = R"({ + "name": "mcp-server", + "transport": "stdio", + "stdio": { + "command": "node", + "args": ["server.js"], + "working_directory": "/app" + }, + "connect_timeout_ms": 5000, + "request_timeout_ms": 30000, + "max_retries": 3 + })"; + + auto result = loader_.parseMCPServerDefinition(JsonValue::parse(json)); + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto def = mcp::get(result); + EXPECT_EQ(def.name, "mcp-server"); + EXPECT_EQ(def.transport, MCPServerDefinition::TransportType::STDIO); + ASSERT_TRUE(def.stdio_config.has_value()); + EXPECT_EQ(def.stdio_config->command, "node"); + EXPECT_EQ(def.stdio_config->args.size(), 1u); + EXPECT_EQ(def.stdio_config->args[0], "server.js"); + EXPECT_EQ(def.connect_timeout, std::chrono::milliseconds(5000)); + EXPECT_EQ(def.request_timeout, std::chrono::milliseconds(30000)); + EXPECT_EQ(def.max_retries, 3u); +} + +TEST_F(ConfigLoaderTest, ParseHTTPSSEServer) { + std::string json = R"({ + "name": "sse-server", + "transport": "http_sse", + "http_sse": { + "url": "${BASE_URL}/sse", + "headers": { + "Authorization": "Bearer ${API_KEY}" + }, + "verify_ssl": false + } + })"; + + auto result = loader_.parseMCPServerDefinition(JsonValue::parse(json)); + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto def = mcp::get(result); + EXPECT_EQ(def.transport, MCPServerDefinition::TransportType::HTTP_SSE); + ASSERT_TRUE(def.http_sse_config.has_value()); + EXPECT_EQ(def.http_sse_config->url, "https://api.test.com/sse"); + EXPECT_EQ(def.http_sse_config->headers["Authorization"], + "Bearer test-key-123"); + EXPECT_FALSE(def.http_sse_config->verify_ssl); +} + +TEST_F(ConfigLoaderTest, ParseAuthPreset) { + std::string json = R"({ + "type": "bearer", + "value": "${API_KEY}", + "header": "X-Custom-Auth" + })"; + + auto result = loader_.parseAuthPreset(JsonValue::parse(json)); + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto auth = mcp::get(result); + EXPECT_EQ(auth.type, AuthPreset::Type::BEARER); + EXPECT_EQ(auth.value, "test-key-123"); + EXPECT_EQ(auth.header, "X-Custom-Auth"); +} + +TEST_F(ConfigLoaderTest, LoadFromString) { + std::string json = R"({ + "name": "test-registry", + "base_url": "${BASE_URL}", + "default_headers": { + "X-API-Key": "${API_KEY}" + }, + "tools": [ + { + "name": "tool1", + "description": "First tool" + }, + { + "name": "tool2", + "description": "Second tool" + } + ], + "mcp_servers": [ + { + "name": "server1", + "transport": "stdio", + "stdio": { + "command": "node", + "args": ["server.js"] + } + } + ] + })"; + + auto result = loader_.loadFromString(json); + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto config = mcp::get(result); + EXPECT_EQ(config.name, "test-registry"); + EXPECT_EQ(config.base_url, "https://api.test.com"); + EXPECT_EQ(config.default_headers["X-API-Key"], "test-key-123"); + EXPECT_EQ(config.tools.size(), 2u); + EXPECT_EQ(config.mcp_servers.size(), 1u); +} + +TEST_F(ConfigLoaderTest, LoadFromStringInvalidJson) { + std::string invalid_json = "{ invalid json }"; + + auto result = loader_.loadFromString(invalid_json); + EXPECT_TRUE(mcp::holds_alternative(result)); +} + +// ============================================================================= +// ToolDefinition Tests +// ============================================================================= + +TEST(ToolDefinitionTest, ToToolSpec) { + ToolDefinition def; + def.name = "test_tool"; + def.description = "Test description"; + def.input_schema = JsonValue::object(); + def.input_schema["type"] = "object"; + + ToolSpec spec = def.toToolSpec(); + + EXPECT_EQ(spec.name, "test_tool"); + EXPECT_EQ(spec.description, "Test description"); + EXPECT_TRUE(spec.parameters.contains("type")); +} + +TEST(ToolDefinitionTest, RESTEndpoint) { + ToolDefinition::RESTEndpoint rest; + rest.method = HttpMethod::POST; + rest.url = "https://api.example.com/search"; + rest.headers["Content-Type"] = "application/json"; + rest.body_mapping["query"] = "$.input.query"; + + EXPECT_EQ(rest.method, HttpMethod::POST); + EXPECT_EQ(rest.url, "https://api.example.com/search"); + EXPECT_EQ(rest.headers["Content-Type"], "application/json"); +} + +TEST(ToolDefinitionTest, MCPToolRef) { + ToolDefinition::MCPToolRef ref; + ref.server_name = "mcp-server"; + ref.tool_name = "remote_tool"; + + EXPECT_EQ(ref.server_name, "mcp-server"); + EXPECT_EQ(ref.tool_name, "remote_tool"); +} + +TEST(MCPServerDefinitionTest, TransportTypes) { + MCPServerDefinition stdio_server; + stdio_server.transport = MCPServerDefinition::TransportType::STDIO; + EXPECT_EQ(stdio_server.transport, MCPServerDefinition::TransportType::STDIO); + + MCPServerDefinition sse_server; + sse_server.transport = MCPServerDefinition::TransportType::HTTP_SSE; + EXPECT_EQ(sse_server.transport, MCPServerDefinition::TransportType::HTTP_SSE); + + MCPServerDefinition ws_server; + ws_server.transport = MCPServerDefinition::TransportType::WEBSOCKET; + EXPECT_EQ(ws_server.transport, MCPServerDefinition::TransportType::WEBSOCKET); +} + +TEST(AuthPresetTest, Types) { + AuthPreset bearer; + bearer.type = AuthPreset::Type::BEARER; + bearer.value = "token123"; + EXPECT_EQ(bearer.type, AuthPreset::Type::BEARER); + + AuthPreset api_key; + api_key.type = AuthPreset::Type::API_KEY; + api_key.value = "key123"; + api_key.header = "X-API-Key"; + EXPECT_EQ(api_key.type, AuthPreset::Type::API_KEY); + + AuthPreset basic; + basic.type = AuthPreset::Type::BASIC; + basic.value = "user:pass"; + EXPECT_EQ(basic.type, AuthPreset::Type::BASIC); +} + +// ============================================================================= +// ToolExecutor Tests +// ============================================================================= + +class ToolExecutorTest : public OrchTest { + protected: + ToolRegistryPtr registry_; + ToolExecutorPtr executor_; + + void SetUp() override { + OrchTest::SetUp(); + registry_ = makeToolRegistry(); + executor_ = makeToolExecutor(registry_); + } +}; + +TEST_F(ToolExecutorTest, CreateExecutor) { + EXPECT_NE(executor_, nullptr); + EXPECT_EQ(executor_->registry(), registry_); +} + +TEST_F(ToolExecutorTest, ExecuteWithNoRegistry) { + auto executor = makeToolExecutor(nullptr); + + auto result = runToCompletionResult([&](Dispatcher& d, + JsonCallback cb) { + executor->executeTool("any_tool", JsonValue::object(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + auto error = mcp::get(result); + EXPECT_TRUE(error.message.find("No registry") != std::string::npos); +} + +TEST_F(ToolExecutorTest, ExecuteEmptyToolCalls) { + std::vector empty_calls; + std::vector> results; + bool done = false; + + executor_->executeToolCalls(empty_calls, true, *dispatcher_, + [&](std::vector> r) { + results = std::move(r); + done = true; + }); + + while (!done) { + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + EXPECT_TRUE(results.empty()); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/tool_runnable_test.cc b/third_party/gopher-orch/tests/gopher/orch/tool_runnable_test.cc new file mode 100644 index 00000000..3df740cc --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/tool_runnable_test.cc @@ -0,0 +1,389 @@ +// Unit tests for ToolRunnable + +#include "gopher/orch/agent/tool_runnable.h" + +#include "orch_test_fixture.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::llm; +using namespace gopher::orch::core; + +// ============================================================================= +// ToolRunnable Test Fixture +// ============================================================================= + +class ToolRunnableTest : public OrchTest { + protected: + ToolRegistryPtr registry_; + ToolExecutorPtr executor_; + ToolRunnable::Ptr tool_runnable_; + + void SetUp() override { + OrchTest::SetUp(); + registry_ = makeToolRegistry(); + executor_ = makeToolExecutor(registry_); + tool_runnable_ = ToolRunnable::create(executor_); + + // Add some test tools + addTestTools(); + } + + void addTestTools() { + // Calculator tool - synchronous + registry_->addSyncTool( + "calculator", "Perform calculations", makeSchema(), + [](const JsonValue& args) -> Result { + if (args.contains("expression") && args["expression"].isString()) { + std::string expr = args["expression"].getString(); + if (expr == "2+2") { + return Result(JsonValue(4)); + } + } + return Result(JsonValue(0)); + }); + + // Search tool - asynchronous + registry_->addTool( + "search", "Search the web", makeSchema(), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + std::string query = "default"; + if (args.contains("query") && args["query"].isString()) { + query = args["query"].getString(); + } + + JsonValue result = JsonValue::object(); + result["query"] = query; + result["results"] = JsonValue::array(); + + d.post([cb = std::move(cb), result = std::move(result)]() mutable { + cb(Result(std::move(result))); + }); + }); + + // Failing tool + registry_->addTool( + "failing_tool", "Always fails", makeSchema(), + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + d.post([cb = std::move(cb)]() { + cb(Result(Error(-1, "Tool execution failed"))); + }); + }); + } + + JsonValue makeSchema() { + JsonValue schema = JsonValue::object(); + schema["type"] = "object"; + return schema; + } +}; + +// ============================================================================= +// Basic Tests +// ============================================================================= + +TEST_F(ToolRunnableTest, Name) { + EXPECT_EQ(tool_runnable_->name(), "ToolRunnable"); +} + +TEST_F(ToolRunnableTest, Accessors) { + EXPECT_EQ(tool_runnable_->executor(), executor_); + EXPECT_EQ(tool_runnable_->registry(), registry_); +} + +// ============================================================================= +// Single Tool Call Tests +// ============================================================================= + +TEST_F(ToolRunnableTest, SingleToolCall) { + JsonValue input = JsonValue::object(); + input["name"] = "calculator"; + JsonValue args = JsonValue::object(); + args["expression"] = "2+2"; + input["arguments"] = args; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result.isObject()); + EXPECT_TRUE(result["success"].getBool()); + EXPECT_EQ(result["result"].getInt(), 4); +} + +TEST_F(ToolRunnableTest, SingleToolCallWithId) { + JsonValue input = JsonValue::object(); + input["id"] = "call_123"; + input["name"] = "calculator"; + JsonValue args = JsonValue::object(); + args["expression"] = "2+2"; + input["arguments"] = args; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result["success"].getBool()); + EXPECT_EQ(result["id"].getString(), "call_123"); + EXPECT_EQ(result["result"].getInt(), 4); +} + +TEST_F(ToolRunnableTest, AsyncToolCall) { + JsonValue input = JsonValue::object(); + input["name"] = "search"; + JsonValue args = JsonValue::object(); + args["query"] = "weather in tokyo"; + input["arguments"] = args; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result["success"].getBool()); + EXPECT_TRUE(result["result"].isObject()); + EXPECT_EQ(result["result"]["query"].getString(), "weather in tokyo"); +} + +TEST_F(ToolRunnableTest, ToolNotFound) { + JsonValue input = JsonValue::object(); + input["name"] = "nonexistent_tool"; + input["arguments"] = JsonValue::object(); + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // Should return success with error in JSON, not fail the Result + EXPECT_TRUE(result.isObject()); + EXPECT_FALSE(result["success"].getBool()); + EXPECT_TRUE(result.contains("error")); +} + +TEST_F(ToolRunnableTest, ToolExecutionFails) { + JsonValue input = JsonValue::object(); + input["id"] = "call_fail"; + input["name"] = "failing_tool"; + input["arguments"] = JsonValue::object(); + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_FALSE(result["success"].getBool()); + EXPECT_EQ(result["id"].getString(), "call_fail"); + EXPECT_EQ(result["error"].getString(), "Tool execution failed"); +} + +TEST_F(ToolRunnableTest, MissingToolName) { + JsonValue input = JsonValue::object(); + input["arguments"] = JsonValue::object(); + // No "name" field + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, + "Invalid tool call input: missing 'name' field"); +} + +TEST_F(ToolRunnableTest, DefaultArguments) { + // Arguments should default to empty object if not provided + JsonValue input = JsonValue::object(); + input["name"] = "search"; + // No "arguments" field + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result["success"].getBool()); + EXPECT_EQ(result["result"]["query"].getString(), "default"); +} + +// ============================================================================= +// Multiple Tool Calls Tests +// ============================================================================= + +TEST_F(ToolRunnableTest, MultipleToolCalls) { + JsonValue input = JsonValue::object(); + JsonValue calls = JsonValue::array(); + + // First call + JsonValue call1 = JsonValue::object(); + call1["id"] = "call_1"; + call1["name"] = "calculator"; + JsonValue args1 = JsonValue::object(); + args1["expression"] = "2+2"; + call1["arguments"] = args1; + calls.push_back(call1); + + // Second call + JsonValue call2 = JsonValue::object(); + call2["id"] = "call_2"; + call2["name"] = "search"; + JsonValue args2 = JsonValue::object(); + args2["query"] = "test query"; + call2["arguments"] = args2; + calls.push_back(call2); + + input["tool_calls"] = calls; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(result.contains("results")); + EXPECT_TRUE(result["results"].isArray()); + EXPECT_EQ(result["results"].size(), 2u); + + // First result + auto& result1 = result["results"][0]; + EXPECT_EQ(result1["id"].getString(), "call_1"); + EXPECT_TRUE(result1["success"].getBool()); + EXPECT_EQ(result1["result"].getInt(), 4); + + // Second result + auto& result2 = result["results"][1]; + EXPECT_EQ(result2["id"].getString(), "call_2"); + EXPECT_TRUE(result2["success"].getBool()); + EXPECT_EQ(result2["result"]["query"].getString(), "test query"); +} + +TEST_F(ToolRunnableTest, MultipleToolCallsWithFailure) { + JsonValue input = JsonValue::object(); + JsonValue calls = JsonValue::array(); + + // Successful call + JsonValue call1 = JsonValue::object(); + call1["id"] = "call_1"; + call1["name"] = "calculator"; + JsonValue args1 = JsonValue::object(); + args1["expression"] = "2+2"; + call1["arguments"] = args1; + calls.push_back(call1); + + // Failing call + JsonValue call2 = JsonValue::object(); + call2["id"] = "call_2"; + call2["name"] = "failing_tool"; + call2["arguments"] = JsonValue::object(); + calls.push_back(call2); + + input["tool_calls"] = calls; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_EQ(result["results"].size(), 2u); + + // First should succeed + EXPECT_TRUE(result["results"][0]["success"].getBool()); + + // Second should fail + EXPECT_FALSE(result["results"][1]["success"].getBool()); + EXPECT_EQ(result["results"][1]["error"].getString(), "Tool execution failed"); +} + +TEST_F(ToolRunnableTest, MultipleToolCallsAutoGenerateIds) { + JsonValue input = JsonValue::object(); + JsonValue calls = JsonValue::array(); + + // Call without id + JsonValue call1 = JsonValue::object(); + call1["name"] = "calculator"; + JsonValue args1 = JsonValue::object(); + args1["expression"] = "2+2"; + call1["arguments"] = args1; + calls.push_back(call1); + + // Another call without id + JsonValue call2 = JsonValue::object(); + call2["name"] = "search"; + JsonValue args2 = JsonValue::object(); + args2["query"] = "test"; + call2["arguments"] = args2; + calls.push_back(call2); + + input["tool_calls"] = calls; + + auto result = runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + // IDs should be auto-generated as "call_0", "call_1" + EXPECT_EQ(result["results"][0]["id"].getString(), "call_0"); + EXPECT_EQ(result["results"][1]["id"].getString(), "call_1"); +} + +TEST_F(ToolRunnableTest, EmptyToolCallsArray) { + JsonValue input = JsonValue::object(); + input["tool_calls"] = JsonValue::array(); + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "Empty tool_calls array"); +} + +// ============================================================================= +// Error Cases +// ============================================================================= + +TEST_F(ToolRunnableTest, NoExecutorError) { + auto runnable_no_executor = ToolRunnable::create(nullptr); + + JsonValue input = JsonValue::object(); + input["name"] = "test"; + input["arguments"] = JsonValue::object(); + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + runnable_no_executor->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "No tool executor configured"); +} + +TEST_F(ToolRunnableTest, InvalidInputType) { + // Non-object input + JsonValue input = JsonValue::array(); + + auto result = runToCompletionResult( + [&](Dispatcher& d, ResultCallback cb) { + tool_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); +} + +// ============================================================================= +// Factory Function Tests +// ============================================================================= + +TEST_F(ToolRunnableTest, MakeToolRunnableFromExecutor) { + auto runnable = makeToolRunnable(executor_); + EXPECT_NE(runnable, nullptr); + EXPECT_EQ(runnable->executor(), executor_); +} + +TEST_F(ToolRunnableTest, MakeToolRunnableFromRegistry) { + auto runnable = makeToolRunnable(registry_); + EXPECT_NE(runnable, nullptr); + EXPECT_EQ(runnable->registry(), registry_); +} diff --git a/third_party/gopher-orch/tests/gopher/orch/tools_fetcher_integration_test.cpp b/third_party/gopher-orch/tests/gopher/orch/tools_fetcher_integration_test.cpp new file mode 100644 index 00000000..7fc34cfc --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/tools_fetcher_integration_test.cpp @@ -0,0 +1,501 @@ +// Integration tests for ToolsFetcher end-to-end functionality + +#include "gopher/orch/agent/tools_fetcher.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/agent/tool_executor.h" +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/server/server_composite.h" +#include "orch_test_fixture.h" +#include "gtest/gtest.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::server; + +// ============================================================================= +// MockMCPServer - Simulates an HTTP+SSE MCP server for testing +// ============================================================================= + +class MockMCPServer { + public: + MockMCPServer(const std::string& name, int port) + : name_(name), port_(port), running_(false), should_fail_(false) { + // Define some mock tools + ServerToolInfo tool1; + tool1.name = name + "_tool1"; + tool1.description = "First tool for " + name; + tool1.inputSchema = JsonValue::object(); + tools_.push_back(tool1); + + ServerToolInfo tool2; + tool2.name = name + "_tool2"; + tool2.description = "Second tool for " + name; + tool2.inputSchema = JsonValue::object(); + tools_.push_back(tool2); + + ServerToolInfo tool3; + tool3.name = name + "_calculator"; + tool3.description = "Calculator for " + name; + tool3.inputSchema = JsonValue::object(); + tools_.push_back(tool3); + } + + ~MockMCPServer() { + stop(); + } + + // Start the mock server + void start() { + if (running_) return; + running_ = true; + + // In a real implementation, this would start an HTTP server + // For integration testing, we simulate a server without actually + // starting one since we can't easily bind to ports in unit tests + // The ToolsFetcher will fail to connect, which we handle gracefully + } + + // Stop the mock server + void stop() { + if (!running_) return; + running_ = false; + } + + // Simulate server failure + void simulateFailure() { + should_fail_ = true; + stop(); + } + + // Get list of tools this server provides + std::vector getTools() const { + return tools_; + } + + // Track if a tool was executed + bool wasToolExecuted(const std::string& tool_name) const { + return executed_tools_.count(tool_name) > 0; + } + + // Get execution count for a tool + int getExecutionCount(const std::string& tool_name) const { + auto it = executed_tools_.find(tool_name); + return it != executed_tools_.end() ? it->second : 0; + } + + bool isRunning() const { return running_; } + const std::string& getName() const { return name_; } + int getPort() const { return port_; } + + private: + JsonValue handleToolExecution(const std::string& tool_name, const JsonValue& args) { + if (should_fail_) { + throw std::runtime_error("Server is simulating failure"); + } + + // Track execution + executed_tools_[tool_name]++; + + // Return mock result + JsonValue result = JsonValue::object(); + result["tool"] = tool_name; + result["status"] = "success"; + result["result"] = "Mock execution of " + tool_name; + return result; + } + + std::string name_; + int port_; + std::atomic running_; + std::atomic should_fail_; + std::vector tools_; + mutable std::map executed_tools_; +}; + +// ============================================================================= +// Integration Test Fixture +// ============================================================================= + +class ToolsFetcherIntegrationTest : public OrchTest { + protected: + std::unique_ptr fetcher_; + std::vector> mock_servers_; + + void SetUp() override { + OrchTest::SetUp(); + fetcher_ = std::make_unique(); + } + + void TearDown() override { + // Stop all mock servers + for (auto& server : mock_servers_) { + server->stop(); + } + mock_servers_.clear(); + fetcher_.reset(); + OrchTest::TearDown(); + } + + // Create and start mock servers + void createMockServers(int count, int starting_port = 4000) { + for (int i = 0; i < count; ++i) { + auto server = std::make_unique( + "mock_server_" + std::to_string(i), + starting_port + i + ); + server->start(); + mock_servers_.push_back(std::move(server)); + } + } + + // Generate configuration JSON for mock servers + std::string generateConfig() { + std::string config = R"({"name": "integration-test-config", "mcp_servers": [)"; + + bool first = true; + for (const auto& mock_server : mock_servers_) { + if (!first) config += ","; + first = false; + + config += R"({ + "name": ")" + mock_server->getName() + R"(", + "transport": "http_sse", + "http_sse": { + "url": "http://localhost:)" + std::to_string(mock_server->getPort()) + R"(", + "headers": { + "Authorization": "Bearer test-key-)" + mock_server->getName() + R"(" + } + }, + "connect_timeout": 5000, + "request_timeout": 10000 + })"; + } + + config += "]}"; + return config; + } + + // Helper to wait for async operation + VoidResult waitForOperation( + std::function)> operation, + int timeout_seconds = 5) { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + VoidResult result = VoidResult(Error(-1, "Operation not completed")); + + operation(*dispatcher_, [&](VoidResult r) { + std::lock_guard lock(mutex); + result = std::move(r); + done = true; + cv.notify_one(); + }); + + // Run dispatcher until done or timeout + auto timeout = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_seconds); + while (!done && std::chrono::steady_clock::now() < timeout) { + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + return result; + } +}; + +// ============================================================================= +// Full Flow Tests +// ============================================================================= + +TEST_F(ToolsFetcherIntegrationTest, FullFlowWithMultipleServers) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + + // Start 3 mock servers + createMockServers(3); + + // Generate config pointing to mock servers + std::string config = generateConfig(); + + // Load config with ToolsFetcher + auto result = waitForOperation([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + // Note: In real integration tests, mock servers would actually be running + // Since we can't easily start real HTTP servers in unit tests, + // this will likely fail to connect but should handle it gracefully + + // Check that ToolsFetcher attempted to create the infrastructure + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); + + // In a real scenario with working servers: + // - Registry should have tools from all 3 servers + // - Composite should aggregate all servers + // - Tool execution should route to correct server +#else + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "MCP support not compiled in"); +#endif +} + +TEST_F(ToolsFetcherIntegrationTest, ToolDiscoveryAndExecution) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + +#ifdef GOPHER_ORCH_WITH_MCP + // Create 2 mock servers + createMockServers(2); + + // Load configuration + std::string config = generateConfig(); + auto load_result = waitForOperation([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + + auto registry = fetcher_->getRegistry(); + ASSERT_NE(registry, nullptr); + + // Create executor for tool execution + auto executor = makeToolExecutor(registry); + + // Try to execute a tool (would work with real servers) + // In this test environment, it will likely fail to connect + // but we're testing the integration setup + + JsonValue args = JsonValue::object(); + args["input"] = "test"; + + auto exec_result = waitForOperation([&executor, &args](Dispatcher& d, auto callback) { + executor->executeTool("mock_server_0_calculator", args, d, + [callback](Result r) { + if (mcp::holds_alternative(r)) { + callback(VoidResult(nullptr)); + } else { + callback(VoidResult(mcp::get(r))); + } + }); + }, 2); // Short timeout since servers aren't really running + + // Would verify execution reached mock server in real test +#else + GTEST_SKIP() << "MCP support not compiled in"; +#endif +} + +// ============================================================================= +// Error Scenario Tests +// ============================================================================= + +TEST_F(ToolsFetcherIntegrationTest, ServerUnavailable) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + + // Create config with non-existent server + std::string config = R"({ + "name": "test-config", + "mcp_servers": [ + { + "name": "unavailable-server", + "transport": "http_sse", + "http_sse": { + "url": "http://localhost:9999", + "headers": {"Authorization": "Bearer test"} + }, + "connect_timeout": 1000, + "request_timeout": 2000 + } + ] + })"; + + auto result = waitForOperation([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }, 3); // Short timeout for unavailable server + +#ifdef GOPHER_ORCH_WITH_MCP + // Should handle connection failure gracefully + // The exact behavior depends on error handling implementation + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +TEST_F(ToolsFetcherIntegrationTest, PartialServerFailure) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + + // Create 3 servers, simulate failure on one + createMockServers(3); + mock_servers_[1]->simulateFailure(); // Fail the middle server + + std::string config = generateConfig(); + auto result = waitForOperation([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + // Should still create registry with working servers + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); + + // In real test: verify that tools from servers 0 and 2 are available + // but not from server 1 +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +TEST_F(ToolsFetcherIntegrationTest, TimeoutHandling) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + + // Create config with very short timeout + std::string config = R"({ + "name": "timeout-test", + "mcp_servers": [ + { + "name": "slow-server", + "transport": "http_sse", + "http_sse": { + "url": "http://localhost:5555", + "headers": {} + }, + "connect_timeout": 100, + "request_timeout": 100 + } + ] + })"; + + auto start = std::chrono::steady_clock::now(); + auto result = waitForOperation([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }, 1); + auto duration = std::chrono::steady_clock::now() - start; + +#ifdef GOPHER_ORCH_WITH_MCP + // Should timeout quickly due to short timeout settings + EXPECT_LT(duration, std::chrono::seconds(2)); + + // Should still create infrastructure even if connection fails + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +// ============================================================================= +// Parallel Server Discovery Tests +// ============================================================================= + +TEST_F(ToolsFetcherIntegrationTest, ParallelServerDiscovery) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + + // Create 5 mock servers to test parallel connection + const int server_count = 5; + createMockServers(server_count); + + std::string config = generateConfig(); + + // Track timing to verify parallel execution + auto start = std::chrono::steady_clock::now(); + auto result = waitForOperation([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }, 10); + auto duration = std::chrono::steady_clock::now() - start; + +#ifdef GOPHER_ORCH_WITH_MCP + // All servers should connect in parallel, not sequentially + // With 5 servers and 5-second timeout each, sequential would take 25+ seconds + // Parallel should complete much faster + EXPECT_LT(duration, std::chrono::seconds(15)); + + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); + + auto composite = fetcher_->getComposite(); + // In real test: verify composite has all expected servers + // composite->servers().size() should equal server_count +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +TEST_F(ToolsFetcherIntegrationTest, LargeScaleParallelDiscovery) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + + // Test with many servers to stress parallel handling + const int server_count = 10; + createMockServers(server_count, 5000); + + std::string config = generateConfig(); + + auto result = waitForOperation([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }, 15); + +#ifdef GOPHER_ORCH_WITH_MCP + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); + + // Each server provides 3 tools, so total should be server_count * 3 + // (in a real test with working servers) + auto registry = fetcher_->getRegistry(); + // EXPECT_GE(registry->toolCount(), server_count * 3); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +// ============================================================================= +// Server Lifecycle Management Tests +// ============================================================================= + +TEST_F(ToolsFetcherIntegrationTest, ServerLifecycleManagement) { + GTEST_SKIP() << "Integration tests require actual MCP servers running"; + + // Test proper cleanup when servers go down + createMockServers(2); + + // Load initial configuration + std::string config1 = generateConfig(); + auto result1 = waitForOperation([this, &config1](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config1, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + auto registry1 = fetcher_->getRegistry(); + auto composite1 = fetcher_->getComposite(); + ASSERT_NE(registry1, nullptr); + ASSERT_NE(composite1, nullptr); + + // Simulate server going down + mock_servers_[0]->stop(); + + // Create new servers and reload + mock_servers_.clear(); + createMockServers(3, 6000); + + std::string config2 = generateConfig(); + auto result2 = waitForOperation([this, &config2](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config2, d, callback); + }); + + // Should have new registry and composite + auto registry2 = fetcher_->getRegistry(); + auto composite2 = fetcher_->getComposite(); + EXPECT_NE(registry2, nullptr); + EXPECT_NE(composite2, nullptr); + EXPECT_NE(registry1, registry2); + EXPECT_NE(composite1, composite2); +#else + EXPECT_TRUE(mcp::holds_alternative(result1)); +#endif +} \ No newline at end of file diff --git a/third_party/gopher-orch/tests/gopher/orch/tools_fetcher_test.cpp b/third_party/gopher-orch/tests/gopher/orch/tools_fetcher_test.cpp new file mode 100644 index 00000000..a3bc77de --- /dev/null +++ b/third_party/gopher-orch/tests/gopher/orch/tools_fetcher_test.cpp @@ -0,0 +1,367 @@ +// Unit tests for ToolsFetcher orchestration layer + +#include "gopher/orch/agent/tools_fetcher.h" + +#include +#include +#include +#include +#include +#include + +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/server/mock_server.h" +#include "gopher/orch/server/server_composite.h" +#include "orch_test_fixture.h" +#include "gtest/gtest.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::server; + +// ============================================================================= +// ToolsFetcher Test Fixture +// ============================================================================= + +class ToolsFetcherTest : public OrchTest { + protected: + std::unique_ptr fetcher_; + + void SetUp() override { + OrchTest::SetUp(); + fetcher_ = std::make_unique(); + } + + // Helper to create a valid JSON config with MCP servers + std::string createValidConfig(int server_count = 2) { + std::string config = R"({ + "name": "test-config", + "mcp_servers": [)"; + + for (int i = 0; i < server_count; ++i) { + if (i > 0) config += ","; + config += R"( + { + "name": "server-)" + std::to_string(i) + R"(", + "transport": "http_sse", + "http_sse": { + "url": "http://localhost:)" + std::to_string(3000 + i) + R"(", + "headers": { + "Authorization": "Bearer test-key" + } + }, + "connect_timeout": 5000, + "request_timeout": 10000 + })"; + } + + config += R"( + ] + })"; + + return config; + } + + // Helper to wait for async operation + VoidResult waitForLoad(std::function)> operation) { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + VoidResult result = VoidResult(Error(-1, "Not completed")); + + operation(*dispatcher_, [&](VoidResult r) { + std::lock_guard lock(mutex); + result = std::move(r); + done = true; + cv.notify_one(); + }); + + // Run dispatcher until done + auto timeout = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!done && std::chrono::steady_clock::now() < timeout) { + dispatcher_->run(mcp::event::RunType::NonBlock); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + return result; + } +}; + +// ============================================================================= +// Basic Creation Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, CreateEmpty) { + EXPECT_EQ(fetcher_->getRegistry(), nullptr); + EXPECT_EQ(fetcher_->getComposite(), nullptr); +} + +// ============================================================================= +// JSON Configuration Parsing Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, LoadValidJsonConfig) { + std::string config = createValidConfig(2); + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + + // With MCP compiled in, this should create empty registry (servers won't connect in test) + // Without MCP, it should return error +#ifdef GOPHER_ORCH_WITH_MCP + EXPECT_TRUE(mcp::holds_alternative(result)); + + // Registry and composite should be created + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_EQ(mcp::get(result).message, "MCP support not compiled in"); +#endif +} + +TEST_F(ToolsFetcherTest, LoadEmptyServerList) { + std::string config = R"({ + "name": "test-config", + "mcp_servers": [] + })"; + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + // Should succeed with empty server list + EXPECT_TRUE(mcp::holds_alternative(result)); + + // Registry should be created but empty + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); + EXPECT_EQ(fetcher_->getRegistry()->toolCount(), 0u); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +// ============================================================================= +// Error Handling Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, LoadInvalidJson) { + std::string config = "{ invalid json"; + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_NE(mcp::get(result).message.find("parse"), std::string::npos); +} + +TEST_F(ToolsFetcherTest, LoadMissingRequiredFields) { + std::string config = R"({ + "name": "test-config" + })"; // Missing mcp_servers + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + + // Should handle missing fields gracefully +#ifdef GOPHER_ORCH_WITH_MCP + // ConfigLoader should provide empty server list if missing + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_NE(fetcher_->getRegistry(), nullptr); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +TEST_F(ToolsFetcherTest, LoadInvalidServerConfig) { + std::string config = R"({ + "name": "test-config", + "mcp_servers": [ + { + "name": "invalid-server" + } + ] + })"; // Missing transport config + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + // Should handle invalid server config + // The exact behavior depends on ConfigLoader validation + EXPECT_NE(fetcher_->getRegistry(), nullptr); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +// ============================================================================= +// File Loading Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, LoadFromFile) { + // Create temp file with config + const char* temp_file = "/tmp/test_tools_config.json"; + std::ofstream file(temp_file); + file << createValidConfig(1); + file.close(); + + auto result = waitForLoad([this, temp_file](Dispatcher& d, auto callback) { + fetcher_->loadFromFile(temp_file, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_NE(fetcher_->getRegistry(), nullptr); + EXPECT_NE(fetcher_->getComposite(), nullptr); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif + + // Clean up temp file + std::remove(temp_file); +} + +TEST_F(ToolsFetcherTest, LoadFromNonexistentFile) { + const char* nonexistent_file = "/tmp/does_not_exist_12345.json"; + + auto result = waitForLoad([this, nonexistent_file](Dispatcher& d, auto callback) { + fetcher_->loadFromFile(nonexistent_file, d, callback); + }); + + EXPECT_TRUE(mcp::holds_alternative(result)); + EXPECT_NE(mcp::get(result).message.find("Cannot open file"), std::string::npos); +} + +// ============================================================================= +// ServerComposite Creation Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, ServerCompositeCreated) { + std::string config = createValidConfig(3); + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto composite = fetcher_->getComposite(); + ASSERT_NE(composite, nullptr); + EXPECT_EQ(composite->name(), "ToolComposite"); + + // Servers should be added to composite (though not connected in test) + // Note: Without actual MCP servers running, we can't verify tool discovery +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +// ============================================================================= +// ToolRegistry Integration Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, ToolRegistryCreated) { + std::string config = createValidConfig(2); + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + EXPECT_TRUE(mcp::holds_alternative(result)); + + auto registry = fetcher_->getRegistry(); + ASSERT_NE(registry, nullptr); + + // Registry should be created but likely empty without real servers + // In a real environment, it would have tools from the MCP servers + EXPECT_GE(registry->toolCount(), 0u); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif +} + +// ============================================================================= +// Multiple Load Calls Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, MultipleLoadCalls) { + std::string config1 = createValidConfig(1); + std::string config2 = createValidConfig(2); + + // First load + auto result1 = waitForLoad([this, &config1](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config1, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + EXPECT_TRUE(mcp::holds_alternative(result1)); + auto registry1 = fetcher_->getRegistry(); + auto composite1 = fetcher_->getComposite(); + + // Second load should replace the previous config + auto result2 = waitForLoad([this, &config2](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config2, d, callback); + }); + + EXPECT_TRUE(mcp::holds_alternative(result2)); + auto registry2 = fetcher_->getRegistry(); + auto composite2 = fetcher_->getComposite(); + + // Should have new instances + EXPECT_NE(registry1, registry2); + EXPECT_NE(composite1, composite2); +#else + EXPECT_TRUE(mcp::holds_alternative(result1)); +#endif +} + +// ============================================================================= +// Environment Variable Tests +// ============================================================================= + +TEST_F(ToolsFetcherTest, EnvironmentVariableSubstitution) { + // Set an environment variable for the test + setenv("TEST_API_KEY", "secret-key-123", 1); + + std::string config = R"({ + "name": "test-config", + "mcp_servers": [ + { + "name": "server-with-env", + "transport": "http_sse", + "http_sse": { + "url": "http://localhost:3000", + "headers": { + "Authorization": "Bearer ${TEST_API_KEY}" + } + }, + "connect_timeout": 5000, + "request_timeout": 10000 + } + ] + })"; + + auto result = waitForLoad([this, &config](Dispatcher& d, auto callback) { + fetcher_->loadFromJson(config, d, callback); + }); + +#ifdef GOPHER_ORCH_WITH_MCP + EXPECT_TRUE(mcp::holds_alternative(result)); + // The ConfigLoader should handle environment variable substitution + EXPECT_NE(fetcher_->getRegistry(), nullptr); +#else + EXPECT_TRUE(mcp::holds_alternative(result)); +#endif + + // Clean up environment variable + unsetenv("TEST_API_KEY"); +} \ No newline at end of file diff --git a/tests/orch/hello_test.cpp b/third_party/gopher-orch/tests/orch/hello_test.cpp similarity index 100% rename from tests/orch/hello_test.cpp rename to third_party/gopher-orch/tests/orch/hello_test.cpp diff --git a/third_party/gopher-orch/third_party/gopher-mcp b/third_party/gopher-orch/third_party/gopher-mcp new file mode 160000 index 00000000..046c7879 --- /dev/null +++ b/third_party/gopher-orch/third_party/gopher-mcp @@ -0,0 +1 @@ +Subproject commit 046c7879d2f4a51c4cdf4b19ec92e812b43bac22 From ed4aa8b8cfcbb015cf6e684b3e83d09e903d98b4 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 12:44:28 +0800 Subject: [PATCH 02/11] Add build.sh for native library compilation Build script that: - Updates git submodules (handles 'update = none' in gopher-orch) - Builds gopher-orch native library with CMake - Fixes macOS dylib install names for Ruby FFI compatibility - Handles multiple libfmt versions (10, 11, 12) - Optionally checks Ruby environment and runs tests --- build.sh | 250 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100755 build.sh diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..436ae27f --- /dev/null +++ b/build.sh @@ -0,0 +1,250 @@ +#!/bin/bash + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NATIVE_DIR="${SCRIPT_DIR}/third_party/gopher-orch" +BUILD_DIR="${NATIVE_DIR}/build" + +# Handle --clean flag (cleans CMake cache but preserves _deps) +if [ "$1" = "--clean" ]; then + echo -e "${YELLOW}Cleaning build artifacts (preserving _deps)...${NC}" + rm -rf "${SCRIPT_DIR}/native" + rm -f "${BUILD_DIR}/CMakeCache.txt" + rm -rf "${BUILD_DIR}/CMakeFiles" + rm -rf "${BUILD_DIR}/lib" + rm -rf "${BUILD_DIR}/bin" + echo -e "${GREEN}✓ Clean complete${NC}" + if [ "$2" != "--build" ]; then + exit 0 + fi +fi + +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}Building gopher-orch Ruby SDK${NC}" +echo -e "${GREEN}======================================${NC}" +echo "" + +# Step 1: Update submodules recursively +echo -e "${YELLOW}Step 1: Updating submodules...${NC}" + +# Support custom SSH host for multiple GitHub accounts +# Usage: GITHUB_SSH_HOST=bettercallsaulj ./build.sh +SSH_HOST="${GITHUB_SSH_HOST:-github.com}" +if [ -n "${GITHUB_SSH_HOST}" ]; then + echo -e "${YELLOW} Using custom SSH host: ${GITHUB_SSH_HOST}${NC}" +fi + +# Configure SSH URL rewrite for GopherSecurity repos +git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" +git config --local submodule.third_party/gopher-orch.url "git@${SSH_HOST}:GopherSecurity/gopher-orch.git" + +# Update main submodule +if ! git submodule update --init 2>/dev/null; then + echo -e "${RED}Error: Failed to clone gopher-orch submodule${NC}" + echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" + echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" + exit 1 +fi + +# Update nested submodule (gopher-mcp inside gopher-orch) +# Note: gopher-orch/.gitmodules has 'update = none' so we must explicitly update +if [ -d "${NATIVE_DIR}" ]; then + cd "${NATIVE_DIR}" + git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" + # Override 'update = none' by using --checkout + git submodule update --init --checkout third_party/gopher-mcp 2>/dev/null || true + # Also update gopher-mcp's nested submodules recursively + if [ -d "third_party/gopher-mcp" ]; then + cd third_party/gopher-mcp + git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" + git submodule update --init --recursive 2>/dev/null || true + fi + cd "${SCRIPT_DIR}" +fi + +echo -e "${GREEN}✓ Submodules updated${NC}" +echo "" + +# Step 2: Check if gopher-orch exists +if [ ! -d "${NATIVE_DIR}" ]; then + echo -e "${RED}Error: gopher-orch submodule not found at ${NATIVE_DIR}${NC}" + echo -e "${RED}Run: git submodule update --init --recursive${NC}" + exit 1 +fi + +# Step 3: Build gopher-orch native library +echo -e "${YELLOW}Step 2: Building gopher-orch native library...${NC}" +cd "${NATIVE_DIR}" + +# Create build directory +if [ ! -d "${BUILD_DIR}" ]; then + mkdir -p "${BUILD_DIR}" +fi + +cd "${BUILD_DIR}" + +# Configure with CMake +echo -e "${YELLOW} Configuring CMake...${NC}" +cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${SCRIPT_DIR}/native" \ + -DBUILD_SHARED_LIBS=ON \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON + +# Build +echo -e "${YELLOW} Compiling...${NC}" +cmake --build . --config Release -j$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) + +# Install to native directory +echo -e "${YELLOW} Installing...${NC}" +cmake --install . + +# Copy dependency libraries (gopher-mcp, fmt) that gopher-orch depends on +echo -e "${YELLOW} Copying dependency libraries...${NC}" +NATIVE_LIB_DIR="${SCRIPT_DIR}/native/lib" +mkdir -p "${NATIVE_LIB_DIR}" + +# Copy gopher-mcp libraries +cp -P "${BUILD_DIR}/lib/libgopher-mcp"*.dylib "${NATIVE_LIB_DIR}/" 2>/dev/null || \ +cp -P "${BUILD_DIR}/lib/libgopher-mcp"*.so "${NATIVE_LIB_DIR}/" 2>/dev/null || true + +# Copy fmt library +cp -P "${BUILD_DIR}/lib/libfmt"*.dylib "${NATIVE_LIB_DIR}/" 2>/dev/null || \ +cp -P "${BUILD_DIR}/lib/libfmt"*.so "${NATIVE_LIB_DIR}/" 2>/dev/null || true + +# Fix dylib install names on macOS (required for Ruby FFI to find dependencies) +if [[ "$OSTYPE" == "darwin"* ]]; then + echo -e "${YELLOW} Fixing dylib install names for macOS...${NC}" + cd "${NATIVE_LIB_DIR}" + + # Find the actual libfmt version (e.g., libfmt.10.dylib or libfmt.11.dylib) + LIBFMT_DYLIB=$(ls libfmt.*.dylib 2>/dev/null | grep -E 'libfmt\.[0-9]+\.dylib$' | head -1) + LIBFMT_VERSION=$(echo "$LIBFMT_DYLIB" | sed 's/libfmt\.\([0-9]*\)\.dylib/\1/') + + # Fix libgopher-orch to use @loader_path for dependencies + if [ -f "libgopher-orch.dylib" ]; then + install_name_tool -id "@rpath/libgopher-orch.dylib" libgopher-orch.dylib 2>/dev/null || true + install_name_tool -change "@rpath/libgopher-mcp.dylib" "@loader_path/libgopher-mcp.dylib" libgopher-orch.dylib 2>/dev/null || true + install_name_tool -change "@rpath/libgopher-mcp-event.dylib" "@loader_path/libgopher-mcp-event.dylib" libgopher-orch.dylib 2>/dev/null || true + # Handle different libfmt versions + for v in 10 11 12; do + install_name_tool -change "@rpath/libfmt.${v}.dylib" "@loader_path/libfmt.${v}.dylib" libgopher-orch.dylib 2>/dev/null || true + install_name_tool -change "libfmt.so.${v}" "@loader_path/libfmt.${v}.dylib" libgopher-orch.dylib 2>/dev/null || true + done + fi + + # Fix libgopher-mcp + if [ -f "libgopher-mcp.dylib" ]; then + install_name_tool -id "@rpath/libgopher-mcp.dylib" libgopher-mcp.dylib 2>/dev/null || true + install_name_tool -change "@rpath/libgopher-mcp-event.dylib" "@loader_path/libgopher-mcp-event.dylib" libgopher-mcp.dylib 2>/dev/null || true + for v in 10 11 12; do + install_name_tool -change "@rpath/libfmt.${v}.dylib" "@loader_path/libfmt.${v}.dylib" libgopher-mcp.dylib 2>/dev/null || true + install_name_tool -change "libfmt.so.${v}" "@loader_path/libfmt.${v}.dylib" libgopher-mcp.dylib 2>/dev/null || true + done + fi + + # Fix libgopher-mcp-event + if [ -f "libgopher-mcp-event.dylib" ]; then + install_name_tool -id "@rpath/libgopher-mcp-event.dylib" libgopher-mcp-event.dylib 2>/dev/null || true + for v in 10 11 12; do + install_name_tool -change "@rpath/libfmt.${v}.dylib" "@loader_path/libfmt.${v}.dylib" libgopher-mcp-event.dylib 2>/dev/null || true + install_name_tool -change "libfmt.so.${v}" "@loader_path/libfmt.${v}.dylib" libgopher-mcp-event.dylib 2>/dev/null || true + done + fi + + # Fix libfmt + if [ -n "$LIBFMT_DYLIB" ] && [ -f "$LIBFMT_DYLIB" ]; then + install_name_tool -id "@rpath/$LIBFMT_DYLIB" "$LIBFMT_DYLIB" 2>/dev/null || true + fi + + cd "${SCRIPT_DIR}" +fi + +echo -e "${GREEN}✓ Native library built successfully${NC}" +echo "" + +# Step 4: Verify build artifacts +echo -e "${YELLOW}Step 3: Verifying native build artifacts...${NC}" + +NATIVE_LIB_DIR="${SCRIPT_DIR}/native/lib" +NATIVE_INCLUDE_DIR="${SCRIPT_DIR}/native/include" + +if [ -d "${NATIVE_LIB_DIR}" ]; then + echo -e "${GREEN}✓ Libraries installed to: ${NATIVE_LIB_DIR}${NC}" + ls -lh "${NATIVE_LIB_DIR}"/*.dylib 2>/dev/null || ls -lh "${NATIVE_LIB_DIR}"/*.so 2>/dev/null || true +else + echo -e "${YELLOW}⚠ Library directory not found: ${NATIVE_LIB_DIR}${NC}" +fi + +if [ -d "${NATIVE_INCLUDE_DIR}" ]; then + echo -e "${GREEN}✓ Headers installed to: ${NATIVE_INCLUDE_DIR}${NC}" +else + echo -e "${YELLOW}⚠ Include directory not found: ${NATIVE_INCLUDE_DIR}${NC}" +fi + +echo "" + +# Step 5: Check Ruby and Bundler (optional) +echo -e "${YELLOW}Step 4: Checking Ruby environment...${NC}" +cd "${SCRIPT_DIR}" + +RUBY_AVAILABLE=false + +# Check for Ruby +if ! command -v ruby &> /dev/null; then + echo -e "${YELLOW}⚠ Ruby not found. Install Ruby to use the SDK:${NC}" + echo -e "${YELLOW} macOS: brew install ruby${NC}" + echo -e "${YELLOW} Linux: sudo apt-get install ruby ruby-dev${NC}" +else + RUBY_AVAILABLE=true + # Check Ruby version + RUBY_VERSION=$(ruby -v | head -n 1 | cut -d ' ' -f 2) + echo -e "${GREEN}✓ Ruby version: ${RUBY_VERSION}${NC}" +fi + +# Check for Bundler +if ! command -v bundle &> /dev/null; then + echo -e "${YELLOW}⚠ Bundler not found. Install with:${NC}" + echo -e "${YELLOW} gem install bundler${NC}" +else + echo -e "${GREEN}✓ Bundler found${NC}" + + # Install dependencies if Gemfile exists + if [ -f "Gemfile" ]; then + echo -e "${YELLOW} Installing dependencies...${NC}" + bundle install --quiet 2>/dev/null || true + echo -e "${GREEN}✓ Dependencies installed${NC}" + fi +fi + +echo "" + +# Step 6: Run tests if RSpec is available +echo -e "${YELLOW}Step 5: Running tests...${NC}" +if [ "$RUBY_AVAILABLE" = false ]; then + echo -e "${YELLOW}⚠ Skipping tests (Ruby not available)${NC}" +elif [ -f "Gemfile" ] && bundle exec rspec --version &> /dev/null; then + bundle exec rspec --format documentation 2>/dev/null && echo -e "${GREEN}✓ Tests passed${NC}" || echo -e "${YELLOW}⚠ Some tests may have failed (native library required)${NC}" +elif command -v rspec &> /dev/null; then + rspec --format documentation 2>/dev/null && echo -e "${GREEN}✓ Tests passed${NC}" || echo -e "${YELLOW}⚠ Some tests may have failed (native library required)${NC}" +else + echo -e "${YELLOW}⚠ RSpec not found, skipping tests${NC}" +fi + +echo "" +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}Build completed successfully!${NC}" +echo -e "${GREEN}======================================${NC}" +echo "" +echo -e "Native libraries: ${YELLOW}${NATIVE_LIB_DIR}${NC}" +echo -e "Native headers: ${YELLOW}${NATIVE_INCLUDE_DIR}${NC}" +echo -e "Run tests: ${YELLOW}bundle exec rspec${NC}" +echo -e "Run example: ${YELLOW}ruby examples/client_example_json.rb${NC}" From 0c00ff91b6e22572a85f1c01a89ae2a32ed5e06b Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 12:46:53 +0800 Subject: [PATCH 03/11] Add Ruby SDK with Bundler build and FFI bindings Features: - FFI bindings using the ffi gem - Builder pattern for configuration - Support for both API key and JSON server config - Automatic resource cleanup with dispose/close - Detailed result information with AgentResult --- Gemfile | 11 ++ Rakefile | 8 ++ gopher_orch.gemspec | 27 ++++ lib/gopher_orch.rb | 54 +++++++ lib/gopher_orch/agent.rb | 166 +++++++++++++++++++++ lib/gopher_orch/agent_result.rb | 66 +++++++++ lib/gopher_orch/agent_result_status.rb | 77 ++++++++++ lib/gopher_orch/config.rb | 35 +++++ lib/gopher_orch/config_builder.rb | 70 +++++++++ lib/gopher_orch/errors.rb | 25 ++++ lib/gopher_orch/native.rb | 191 +++++++++++++++++++++++++ lib/gopher_orch/version.rb | 5 + 12 files changed, 735 insertions(+) create mode 100644 Gemfile create mode 100644 Rakefile create mode 100644 gopher_orch.gemspec create mode 100644 lib/gopher_orch.rb create mode 100644 lib/gopher_orch/agent.rb create mode 100644 lib/gopher_orch/agent_result.rb create mode 100644 lib/gopher_orch/agent_result_status.rb create mode 100644 lib/gopher_orch/config.rb create mode 100644 lib/gopher_orch/config_builder.rb create mode 100644 lib/gopher_orch/errors.rb create mode 100644 lib/gopher_orch/native.rb create mode 100644 lib/gopher_orch/version.rb diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..f6c2db1c --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec + +group :development, :test do + gem 'rake', '~> 13.0' + gem 'rspec', '~> 3.12' + gem 'rubocop', '~> 1.50', require: false +end diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..82bb534a --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/gopher_orch.gemspec b/gopher_orch.gemspec new file mode 100644 index 00000000..31cd3594 --- /dev/null +++ b/gopher_orch.gemspec @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = 'gopher_orch' + spec.version = '0.1.0' + spec.authors = ['GopherSecurity'] + spec.email = ['dev@gophersecurity.com'] + + spec.summary = 'Ruby SDK for Gopher Orch - AI Agent orchestration framework' + spec.description = 'Ruby bindings for the gopher-orch native library, providing AI agent orchestration with MCP (Model Context Protocol) support.' + spec.homepage = 'https://github.com/GopherSecurity/gopher-mcp-ruby' + spec.license = 'MIT' + spec.required_ruby_version = '>= 2.7.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = spec.homepage + spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" + + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) + end + end + spec.require_paths = ['lib'] + + spec.add_dependency 'ffi', '~> 1.15' +end diff --git a/lib/gopher_orch.rb b/lib/gopher_orch.rb new file mode 100644 index 00000000..1fae855f --- /dev/null +++ b/lib/gopher_orch.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'ffi' + +require_relative 'gopher_orch/version' +require_relative 'gopher_orch/errors' +require_relative 'gopher_orch/native' +require_relative 'gopher_orch/agent_result_status' +require_relative 'gopher_orch/agent_result' +require_relative 'gopher_orch/config' +require_relative 'gopher_orch/config_builder' +require_relative 'gopher_orch/agent' + +module GopherOrch + class << self + # Check if the native library is available + # + # @return [Boolean] true if the library is available + def available? + Native.available? + end + + # Initialize the native library + # + # @raise [LibraryError] if the library cannot be loaded + def init! + Native.init! + end + + # Check if the library is initialized + # + # @return [Boolean] true if initialized + def initialized? + Native.initialized? + end + + # Shutdown the library + def shutdown + Native.shutdown + end + + # Get the last error message from the native library + # + # @return [String, nil] the error message or nil + def last_error + Native.last_error + end + + # Clear the last error + def clear_error + Native.clear_error + end + end +end diff --git a/lib/gopher_orch/agent.rb b/lib/gopher_orch/agent.rb new file mode 100644 index 00000000..eebfc789 --- /dev/null +++ b/lib/gopher_orch/agent.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module GopherOrch + # A gopher-orch agent for running AI queries + class Agent + # Default timeout for agent queries (60 seconds) + DEFAULT_TIMEOUT_MS = 60_000 + + # Create a new GopherAgent with the given configuration + # + # @param config [Config] the agent configuration + # @return [Agent] + # @raise [ConfigError] if configuration is invalid + # @raise [AgentError] if agent creation fails + def self.create(config) + Native.init! + + handle = if config.api_key? + Native.agent_create_by_api_key( + config.provider, + config.model, + config.api_key + ) + elsif config.server_config? + Native.agent_create_by_json( + config.provider, + config.model, + config.server_config + ) + else + raise ConfigError, 'Either API key or server config must be provided' + end + + if handle.nil? || handle.null? + error_msg = Native.last_error + Native.clear_error + message = error_msg && !error_msg.empty? ? error_msg : 'Failed to create agent' + raise AgentError, message + end + + new(handle) + end + + # Create a new GopherAgent with an API key + # + # @param provider [String] the LLM provider name + # @param model [String] the model name + # @param api_key [String] the API key + # @return [Agent] + # @raise [AgentError] if agent creation fails + def self.create_with_api_key(provider, model, api_key) + config = ConfigBuilder.create + .with_provider(provider) + .with_model(model) + .with_api_key(api_key) + .build + + create(config) + end + + # Create a new GopherAgent with a server config + # + # @param provider [String] the LLM provider name + # @param model [String] the model name + # @param server_config [String] the JSON server configuration + # @return [Agent] + # @raise [AgentError] if agent creation fails + def self.create_with_server_config(provider, model, server_config) + config = ConfigBuilder.create + .with_provider(provider) + .with_model(model) + .with_server_config(server_config) + .build + + create(config) + end + + # @param handle [FFI::Pointer] the native agent handle + def initialize(handle) + @handle = handle + @disposed = false + end + + # Run a query against the agent with the default timeout (60 seconds) + # + # @param query [String] the query string + # @return [String] the response + # @raise [DisposedError] if the agent has been disposed + def run(query) + run_with_timeout(query, DEFAULT_TIMEOUT_MS) + end + + # Run a query against the agent with a custom timeout + # + # @param query [String] the query string + # @param timeout_ms [Integer] timeout in milliseconds + # @return [String] the response + # @raise [DisposedError] if the agent has been disposed + def run_with_timeout(query, timeout_ms) + ensure_not_disposed! + + response = Native.agent_run(@handle, query, timeout_ms) + + return "No response for query: \"#{query}\"" if response.empty? + + response + end + + # Run a query and return detailed result information + # + # @param query [String] the query string + # @return [AgentResult] + def run_detailed(query) + run_detailed_with_timeout(query, DEFAULT_TIMEOUT_MS) + end + + # Run a query with custom timeout and return detailed result + # + # @param query [String] the query string + # @param timeout_ms [Integer] timeout in milliseconds + # @return [AgentResult] + def run_detailed_with_timeout(query, timeout_ms) + response = run_with_timeout(query, timeout_ms) + AgentResult.success(response) + rescue DisposedError => e + AgentResult.error(e.message) + rescue StandardError => e + if e.message.downcase.include?('timeout') + AgentResult.timeout(e.message) + else + AgentResult.error(e.message) + end + end + + # Check if the agent has been disposed + # + # @return [Boolean] + def disposed? + @disposed + end + + # Dispose of the agent, releasing native resources + def dispose + return if @disposed + + @disposed = true + + return unless @handle + + Native.agent_release(@handle) + @handle = nil + end + + # Alias for dispose + alias close dispose + + private + + # Ensure the agent has not been disposed + # + # @raise [DisposedError] + def ensure_not_disposed! + raise DisposedError if @disposed + end + end +end diff --git a/lib/gopher_orch/agent_result.rb b/lib/gopher_orch/agent_result.rb new file mode 100644 index 00000000..bd2e70ba --- /dev/null +++ b/lib/gopher_orch/agent_result.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module GopherOrch + # Result of an agent query with detailed information + class AgentResult + attr_reader :response, :status, :error_message, :iteration_count, :tokens_used + + # Create a new result + # + # @param response [String] the response content + # @param status [AgentResultStatus] the result status + # @param error_message [String, nil] the error message + # @param iteration_count [Integer] the iteration count + # @param tokens_used [Integer] the number of tokens used + def initialize(response:, status:, error_message: nil, iteration_count: 0, tokens_used: 0) + @response = response + @status = status + @error_message = error_message + @iteration_count = iteration_count + @tokens_used = tokens_used + end + + # Create a successful result + # + # @param response [String] the response content + # @return [AgentResult] + def self.success(response) + new( + response: response, + status: AgentResultStatus.success, + iteration_count: 1 + ) + end + + # Create an error result + # + # @param message [String] the error message + # @return [AgentResult] + def self.error(message) + new( + response: '', + status: AgentResultStatus.error, + error_message: message + ) + end + + # Create a timeout result + # + # @param message [String] the timeout message + # @return [AgentResult] + def self.timeout(message) + new( + response: '', + status: AgentResultStatus.timeout, + error_message: message + ) + end + + # Check if the result was successful + # + # @return [Boolean] + def success? + @status.success? + end + end +end diff --git a/lib/gopher_orch/agent_result_status.rb b/lib/gopher_orch/agent_result_status.rb new file mode 100644 index 00000000..593a7601 --- /dev/null +++ b/lib/gopher_orch/agent_result_status.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module GopherOrch + # Status of an agent result + class AgentResultStatus + SUCCESS = 'SUCCESS' + ERROR = 'ERROR' + TIMEOUT = 'TIMEOUT' + MAX_ITERATIONS_REACHED = 'MAX_ITERATIONS_REACHED' + + attr_reader :value + + # Create a new status + # + # @param value [String] the status value + def initialize(value) + @value = value + end + + # Create a success status + # + # @return [AgentResultStatus] + def self.success + new(SUCCESS) + end + + # Create an error status + # + # @return [AgentResultStatus] + def self.error + new(ERROR) + end + + # Create a timeout status + # + # @return [AgentResultStatus] + def self.timeout + new(TIMEOUT) + end + + # Create a max iterations reached status + # + # @return [AgentResultStatus] + def self.max_iterations_reached + new(MAX_ITERATIONS_REACHED) + end + + # Check if the status is success + # + # @return [Boolean] + def success? + @value == SUCCESS + end + + # String representation + # + # @return [String] + def to_s + @value + end + + # Compare with another status + # + # @param other [AgentResultStatus, String] the other status + # @return [Boolean] + def ==(other) + case other + when AgentResultStatus + @value == other.value + when String + @value == other + else + false + end + end + end +end diff --git a/lib/gopher_orch/config.rb b/lib/gopher_orch/config.rb new file mode 100644 index 00000000..6814b4a5 --- /dev/null +++ b/lib/gopher_orch/config.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module GopherOrch + # Configuration for creating a GopherAgent + class Config + attr_reader :provider, :model, :api_key, :server_config + + # Create a new configuration + # + # @param provider [String] the LLM provider name + # @param model [String] the model name + # @param api_key [String, nil] the API key (mutually exclusive with server_config) + # @param server_config [String, nil] the JSON server configuration + def initialize(provider:, model:, api_key: nil, server_config: nil) + @provider = provider + @model = model + @api_key = api_key + @server_config = server_config + end + + # Check if an API key is set + # + # @return [Boolean] + def api_key? + !@api_key.nil? && !@api_key.empty? + end + + # Check if a server configuration is set + # + # @return [Boolean] + def server_config? + !@server_config.nil? && !@server_config.empty? + end + end +end diff --git a/lib/gopher_orch/config_builder.rb b/lib/gopher_orch/config_builder.rb new file mode 100644 index 00000000..740ee5c9 --- /dev/null +++ b/lib/gopher_orch/config_builder.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module GopherOrch + # Builder for creating Config instances + class ConfigBuilder + def initialize + @provider = '' + @model = '' + @api_key = nil + @server_config = nil + end + + # Create a new ConfigBuilder + # + # @return [ConfigBuilder] + def self.create + new + end + + # Set the LLM provider + # + # @param provider [String] the provider name (e.g., "AnthropicProvider") + # @return [self] + def with_provider(provider) + @provider = provider + self + end + + # Set the model name + # + # @param model [String] the model name (e.g., "claude-3-haiku-20240307") + # @return [self] + def with_model(model) + @model = model + self + end + + # Set the API key for fetching remote server config + # Mutually exclusive with server_config + # + # @param api_key [String] the API key + # @return [self] + def with_api_key(api_key) + @api_key = api_key + self + end + + # Set the JSON server configuration + # Mutually exclusive with api_key + # + # @param server_config [String] the JSON server configuration + # @return [self] + def with_server_config(server_config) + @server_config = server_config + self + end + + # Build the Config instance + # + # @return [Config] + def build + Config.new( + provider: @provider, + model: @model, + api_key: @api_key, + server_config: @server_config + ) + end + end +end diff --git a/lib/gopher_orch/errors.rb b/lib/gopher_orch/errors.rb new file mode 100644 index 00000000..24d185d9 --- /dev/null +++ b/lib/gopher_orch/errors.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module GopherOrch + # Base error class for all GopherOrch errors + class Error < StandardError; end + + # Raised when the native library cannot be loaded or found + class LibraryError < Error; end + + # Raised when configuration is invalid + class ConfigError < Error; end + + # Raised when agent operations fail + class AgentError < Error; end + + # Raised when an operation times out + class TimeoutError < AgentError; end + + # Raised when trying to use a disposed agent + class DisposedError < AgentError + def initialize(msg = 'Agent has been disposed') + super + end + end +end diff --git a/lib/gopher_orch/native.rb b/lib/gopher_orch/native.rb new file mode 100644 index 00000000..e8809134 --- /dev/null +++ b/lib/gopher_orch/native.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'ffi' + +module GopherOrch + # FFI bindings to the native gopher-orch library + module Native + extend FFI::Library + + # Error info structure from the native library + class ErrorInfo < FFI::Struct + layout :code, :int, + :message, :pointer, + :details, :pointer, + :file, :pointer, + :line, :int + end + + @initialized = false + @library_path = nil + + class << self + attr_reader :library_path + + # Initialize the native library + # + # @raise [LibraryError] if the library cannot be loaded + def init! + return if @initialized + + path = find_library + raise LibraryError, 'Failed to find gopher-orch native library. Run ./build.sh first.' unless path + + begin + ffi_lib path + attach_functions + @library_path = path + @initialized = true + rescue FFI::NotFoundError, LoadError => e + raise LibraryError, "Failed to load gopher-orch native library: #{e.message}" + end + end + + # Check if the library is initialized + # + # @return [Boolean] + def initialized? + @initialized + end + + # Check if the native library is available + # + # @return [Boolean] + def available? + init! + true + rescue LibraryError + false + end + + # Shutdown the library + def shutdown + @initialized = false + @library_path = nil + end + + # Get the last error message + # + # @return [String, nil] + def last_error + return nil unless @initialized + + error_info = gopher_orch_last_error + return nil if error_info.null? + + msg_ptr = error_info[:message] + return nil if msg_ptr.null? + + msg_ptr.read_string + end + + # Clear the last error + def clear_error + return unless @initialized + + gopher_orch_clear_error + end + + # Create an agent with JSON server configuration + # + # @param provider [String] the LLM provider name + # @param model [String] the model name + # @param server_json [String] the JSON server configuration + # @return [FFI::Pointer] the agent handle + def agent_create_by_json(provider, model, server_json) + init! + gopher_orch_agent_create_by_json(provider, model, server_json) + end + + # Create an agent with API key + # + # @param provider [String] the LLM provider name + # @param model [String] the model name + # @param api_key [String] the API key + # @return [FFI::Pointer] the agent handle + def agent_create_by_api_key(provider, model, api_key) + init! + gopher_orch_agent_create_by_api_key(provider, model, api_key) + end + + # Run a query against the agent + # + # @param agent [FFI::Pointer] the agent handle + # @param query [String] the query string + # @param timeout_ms [Integer] timeout in milliseconds + # @return [String] the response + def agent_run(agent, query, timeout_ms) + init! + result_ptr = gopher_orch_agent_run(agent, query, timeout_ms) + return '' if result_ptr.null? + + result = result_ptr.read_string + gopher_orch_free(result_ptr) + result + end + + # Release an agent handle + # + # @param agent [FFI::Pointer] the agent handle + def agent_release(agent) + return if agent.nil? || agent.null? + return unless @initialized + + gopher_orch_agent_release(agent) + end + + private + + # Find the native library path + # + # @return [String, nil] + def find_library + extension = case FFI::Platform::OS + when 'darwin' then 'dylib' + when 'windows' then 'dll' + else 'so' + end + + lib_name = FFI::Platform::OS == 'windows' ? 'gopher-orch' : 'libgopher-orch' + + candidates = [ + # Relative to current working directory + File.join(Dir.pwd, 'native', 'lib', "#{lib_name}.#{extension}"), + # Relative to this file + File.join(File.dirname(__FILE__), '..', '..', 'native', 'lib', "#{lib_name}.#{extension}"), + # System paths + "/usr/local/lib/#{lib_name}.#{extension}", + "/opt/homebrew/lib/#{lib_name}.#{extension}" + ] + + # Check environment variable + env_path = ENV['GOPHER_ORCH_LIBRARY_PATH'] + candidates.unshift(env_path) if env_path + + candidates.find { |path| File.exist?(path) } + end + + # Attach FFI functions + def attach_functions + attach_function :gopher_orch_agent_create_by_json, + %i[string string string], :pointer + attach_function :gopher_orch_agent_create_by_api_key, + %i[string string string], :pointer + attach_function :gopher_orch_agent_run, + %i[pointer string uint64], :pointer + attach_function :gopher_orch_agent_release, + [:pointer], :void + attach_function :gopher_orch_agent_add_ref, + [:pointer], :void + attach_function :gopher_orch_api_fetch_servers, + [:string], :pointer + attach_function :gopher_orch_last_error, + [], ErrorInfo.by_ref + attach_function :gopher_orch_clear_error, + [], :void + attach_function :gopher_orch_free, + [:pointer], :void + end + end + end +end diff --git a/lib/gopher_orch/version.rb b/lib/gopher_orch/version.rb new file mode 100644 index 00000000..f790e7f7 --- /dev/null +++ b/lib/gopher_orch/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module GopherOrch + VERSION = '0.1.0' +end From 21166e5904df478bca1e58a9c9526f608196c6b7 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 12:48:22 +0800 Subject: [PATCH 04/11] Add Ruby SDK examples with MCP server integration Examples: - client_example_json.rb: Demonstrates agent creation and query execution using JSON server configuration with local MCP servers - client_example_json_run.sh: Helper script to start MCP servers and run the Ruby client example MCP Servers (TypeScript/Express): - server3001: Weather tools MCP server (port 3001) - server3002: Calculator tools MCP server (port 3002) --- examples/client_example_json.rb | 67 + examples/client_example_json_run.sh | 91 + examples/server3001/README.md | 21 + examples/server3001/package-lock.json | 1644 +++++++++++++++++ examples/server3001/package.json | 35 + examples/server3001/src/index.ts | 143 ++ examples/server3001/src/tools/get-alerts.ts | 57 + examples/server3001/src/tools/get-forecast.ts | 58 + examples/server3001/src/tools/get-weather.ts | 45 + examples/server3001/start-mcp-server.sh | 24 + examples/server3001/tsconfig.json | 21 + examples/server3002/README.md | 20 + examples/server3002/package-lock.json | 1644 +++++++++++++++++ examples/server3002/package.json | 35 + examples/server3002/src/index.ts | 138 ++ .../server3002/src/tools/generate-password.ts | 196 ++ examples/server3002/src/tools/get-time.ts | 130 ++ examples/server3002/start-mcp-server.sh | 24 + examples/server3002/tsconfig.json | 21 + 19 files changed, 4414 insertions(+) create mode 100755 examples/client_example_json.rb create mode 100755 examples/client_example_json_run.sh create mode 100644 examples/server3001/README.md create mode 100644 examples/server3001/package-lock.json create mode 100644 examples/server3001/package.json create mode 100644 examples/server3001/src/index.ts create mode 100644 examples/server3001/src/tools/get-alerts.ts create mode 100644 examples/server3001/src/tools/get-forecast.ts create mode 100644 examples/server3001/src/tools/get-weather.ts create mode 100755 examples/server3001/start-mcp-server.sh create mode 100644 examples/server3001/tsconfig.json create mode 100644 examples/server3002/README.md create mode 100644 examples/server3002/package-lock.json create mode 100644 examples/server3002/package.json create mode 100644 examples/server3002/src/index.ts create mode 100644 examples/server3002/src/tools/generate-password.ts create mode 100644 examples/server3002/src/tools/get-time.ts create mode 100755 examples/server3002/start-mcp-server.sh create mode 100644 examples/server3002/tsconfig.json diff --git a/examples/client_example_json.rb b/examples/client_example_json.rb new file mode 100755 index 00000000..9e345f6a --- /dev/null +++ b/examples/client_example_json.rb @@ -0,0 +1,67 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Example using JSON server configuration. + +require_relative '../lib/gopher_orch' + +# Server configuration for local MCP servers +SERVER_CONFIG = <<~JSON + { + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1", + "name": "server1", + "transport": "http_sse", + "config": {"url": "http://127.0.0.1:3001/mcp", "headers": {}}, + "connectTimeout": 5000, + "requestTimeout": 30000 + }, + { + "version": "2025-01-09", + "serverId": "2", + "name": "server2", + "transport": "http_sse", + "config": {"url": "http://127.0.0.1:3002/mcp", "headers": {}}, + "connectTimeout": 5000, + "requestTimeout": 30000 + } + ] + } + } +JSON + +provider = 'AnthropicProvider' +model = 'claude-3-haiku-20240307' + +begin + # Create agent with JSON server configuration + config = GopherOrch::ConfigBuilder.create + .with_provider(provider) + .with_model(model) + .with_server_config(SERVER_CONFIG) + .build + + agent = GopherOrch::Agent.create(config) + puts 'GopherAgent created!' + + # Get question from command line args or use default + question = ARGV.empty? ? 'What is the weather like in New York?' : ARGV.join(' ') + puts "Question: #{question}" + + # Run the query + answer = agent.run(question) + puts 'Answer:' + puts answer + + # Cleanup (optional - happens automatically) + agent.dispose +rescue GopherOrch::Error => e + warn "Error: #{e.message}" + exit 1 +end diff --git a/examples/client_example_json_run.sh b/examples/client_example_json_run.sh new file mode 100755 index 00000000..767d0d29 --- /dev/null +++ b/examples/client_example_json_run.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# Run the Ruby client example with local MCP servers + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Cleanup function - kill process groups to ensure all child processes are terminated +cleanup() { + echo -e "\n${YELLOW}Cleaning up...${NC}" + # Kill by process group (negative PID) + if [ -n "$SERVER3001_PID" ]; then + kill -- -$SERVER3001_PID 2>/dev/null || kill $SERVER3001_PID 2>/dev/null || true + fi + if [ -n "$SERVER3002_PID" ]; then + kill -- -$SERVER3002_PID 2>/dev/null || kill $SERVER3002_PID 2>/dev/null || true + fi + # Also kill any remaining node processes on the specific ports + lsof -ti:3001 | xargs kill 2>/dev/null || true + lsof -ti:3002 | xargs kill 2>/dev/null || true + echo -e "${GREEN}Done${NC}" +} + +trap cleanup EXIT INT TERM + +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}Running Ruby Client Example${NC}" +echo -e "${GREEN}======================================${NC}" +echo "" + +# Check if native library exists +if [ ! -d "$PROJECT_DIR/native/lib" ]; then + echo -e "${RED}Error: Native library not found at $PROJECT_DIR/native/lib${NC}" + echo -e "${YELLOW}Please run ./build.sh first${NC}" + exit 1 +fi + +# Check if bundler is installed and install gems if needed +if command -v bundle &> /dev/null; then + cd "$PROJECT_DIR" + if [ ! -f "Gemfile.lock" ]; then + echo -e "${YELLOW}Installing Ruby dependencies...${NC}" + bundle install + fi +fi + +# Start server3001 +echo -e "${YELLOW}Starting server3001...${NC}" +cd "$SCRIPT_DIR/server3001" +if [ ! -d "node_modules" ]; then + echo -e "${YELLOW}Installing dependencies for server3001...${NC}" + npm install +fi +npm run dev & +SERVER3001_PID=$! +echo -e "${GREEN}server3001 started (PID: $SERVER3001_PID)${NC}" + +# Start server3002 +echo -e "${YELLOW}Starting server3002...${NC}" +cd "$SCRIPT_DIR/server3002" +if [ ! -d "node_modules" ]; then + echo -e "${YELLOW}Installing dependencies for server3002...${NC}" + npm install +fi +npm run dev & +SERVER3002_PID=$! +echo -e "${GREEN}server3002 started (PID: $SERVER3002_PID)${NC}" + +# Wait for servers to start +echo -e "${YELLOW}Waiting for servers to start...${NC}" +sleep 3 + +# Run the Ruby client +echo "" +echo -e "${YELLOW}Running Ruby client...${NC}" +echo "" +cd "$PROJECT_DIR" + +ruby examples/client_example_json.rb "$@" + +echo "" +echo -e "${GREEN}Example completed${NC}" diff --git a/examples/server3001/README.md b/examples/server3001/README.md new file mode 100644 index 00000000..379c47a8 --- /dev/null +++ b/examples/server3001/README.md @@ -0,0 +1,21 @@ +# MCP Server 3001 + +Simple MCP server with weather tools. + +## Tools + +- `get-weather` - Get current weather for a city +- `get-forecast` - Get weather forecast for a city +- `get-weather-alerts` - Get weather alerts for a region + +## Quick Start + +```bash +npm install +npm run dev +``` + +## Endpoints + +- MCP: http://127.0.0.1:3001/mcp +- Health: http://127.0.0.1:3001/health diff --git a/examples/server3001/package-lock.json b/examples/server3001/package-lock.json new file mode 100644 index 00000000..fbe27aa0 --- /dev/null +++ b/examples/server3001/package-lock.json @@ -0,0 +1,1644 @@ +{ + "name": "mcp-server-3001", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-server-3001", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "body-parser": "^2.2.0", + "cors": "^2.8.5", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.5", + "@types/node": "^20.11.5", + "tsx": "^4.7.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "../../mcp-cpp-sdk/sdk/typescript": { + "name": "@mcp/filter-sdk", + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.0", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "jose": "^5.10.0", + "koffi": "^2.13.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.4", + "@types/jest": "^29.5.14", + "@types/node": "^18.19.123", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.0.0", + "jest": "^29.0.0", + "prettier": "^3.6.2", + "ts-jest": "^29.0.0", + "tsx": "^4.20.6", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0 <19.0.0" + } + }, + "../gopher-auth-sdk-nodejs": { + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "axios": "^1.6.5", + "express": "^5.1.0", + "jose": "^5.2.0" + }, + "devDependencies": { + "@types/express": "^5.0.5", + "@types/jest": "^29.5.11", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "prettier": "^3.2.4", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "sdk": { + "name": "@mcp/filter-sdk", + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.0", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "jose": "^5.10.0", + "koffi": "^2.13.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.4", + "@types/jest": "^29.5.14", + "@types/node": "^18.19.123", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.0.0", + "jest": "^29.0.0", + "prettier": "^3.6.2", + "ts-jest": "^29.0.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16.0.0 <19.0.0" + } + } + } +} diff --git a/examples/server3001/package.json b/examples/server3001/package.json new file mode 100644 index 00000000..fa6c1510 --- /dev/null +++ b/examples/server3001/package.json @@ -0,0 +1,35 @@ +{ + "name": "mcp-server-3001", + "version": "1.0.0", + "description": "Simple MCP server (weather tools)", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "start": "node dist/src/index.js", + "lint": "eslint src --ext .ts", + "format": "prettier --write \"src/**/*.ts\"" + }, + "keywords": [ + "mcp", + "model-context-protocol" + ], + "author": "", + "license": "MIT", + "dependencies": { + "body-parser": "^2.2.0", + "cors": "^2.8.5", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.5", + "@types/node": "^20.11.5", + "tsx": "^4.7.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/server3001/src/index.ts b/examples/server3001/src/index.ts new file mode 100644 index 00000000..a6a356f6 --- /dev/null +++ b/examples/server3001/src/index.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +/** + * Simple MCP Server (No Authentication) + */ + +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import bodyParser from 'body-parser'; + +import { getWeather } from './tools/get-weather.js'; +import { getForecast } from './tools/get-forecast.js'; +import { getAlerts } from './tools/get-alerts.js'; + +const SERVER_PORT = parseInt(process.env.SERVER_PORT || '3001', 10); +const SERVER_URL = process.env.SERVER_URL || `http://127.0.0.1:${SERVER_PORT}`; +const SERVER_NAME = process.env.SERVER_NAME || 'mcp-server-3001'; +const SERVER_VERSION = process.env.SERVER_VERSION || '1.0.0'; + +const TOOLS = [ + { + name: 'get-weather', + description: 'Get current weather for a city', + inputSchema: { + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + }, + }, + { + name: 'get-forecast', + description: 'Get weather forecast for a city', + inputSchema: { + type: 'object', + properties: { + city: { type: 'string', description: 'City name' }, + days: { type: 'number', description: 'Days (1-7)', minimum: 1, maximum: 7 }, + }, + required: ['city'], + }, + }, + { + name: 'get-weather-alerts', + description: 'Get weather alerts for a region', + inputSchema: { + type: 'object', + properties: { region: { type: 'string', description: 'Region name' } }, + required: ['region'], + }, + }, +]; + +async function startServer() { + const app = express(); + + app.use(cors({ origin: true, credentials: true })); + app.use(bodyParser.json()); + + // Health check + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'healthy', timestamp: new Date().toISOString() }); + }); + + // MCP endpoint + app.all('/mcp', async (req: Request, res: Response) => { + const { method, params, id } = req.body || {}; + let response: any; + + switch (method) { + case 'initialize': + response = { + jsonrpc: '2.0', + result: { + protocolVersion: params?.protocolVersion || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }, + id, + }; + break; + + case 'tools/list': + response = { jsonrpc: '2.0', result: { tools: TOOLS }, id }; + break; + + case 'tools/call': + try { + const toolName = params?.name; + let result: any; + + switch (toolName) { + case 'get-weather': + result = await getWeather.handler(req.body); + break; + case 'get-forecast': + result = await getForecast.handler(req.body); + break; + case 'get-weather-alerts': + result = await getAlerts.handler(req.body); + break; + default: + result = { + content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], + isError: true, + }; + } + response = { jsonrpc: '2.0', result, id }; + } catch (error) { + response = { + jsonrpc: '2.0', + error: { code: -32603, message: error instanceof Error ? error.message : 'Error' }, + id, + }; + } + break; + + default: + response = { + jsonrpc: '2.0', + error: { code: -32601, message: `Method not found: ${method}` }, + id, + }; + } + + res.json(response); + }); + + app.listen(SERVER_PORT, '127.0.0.1', () => { + console.log(`MCP Server running at ${SERVER_URL}`); + console.log(` POST ${SERVER_URL}/mcp`); + console.log(` GET ${SERVER_URL}/health`); + }); + + process.on('SIGINT', () => { + console.log('\nShutting down...'); + process.exit(0); + }); +} + +startServer().catch(error => { + console.error('Failed to start server:', error); + process.exit(1); +}); diff --git a/examples/server3001/src/tools/get-alerts.ts b/examples/server3001/src/tools/get-alerts.ts new file mode 100644 index 00000000..653d5062 --- /dev/null +++ b/examples/server3001/src/tools/get-alerts.ts @@ -0,0 +1,57 @@ +/** + * Get weather alerts + * Requires mcp:admin scope for severe weather alerts + */ +export const getAlerts = { + name: 'get-weather-alerts', + description: 'Get weather alerts and warnings for a region (requires mcp:admin scope)', + inputSchema: { + type: 'object', + properties: { + region: { + type: 'string', + description: 'Region or city name', + }, + }, + required: ['region'], + }, + handler: async (request: any) => { + const { region } = request.params; + + // Simulate weather alerts + const alerts = [ + { + severity: 'moderate', + type: 'Heavy Rain', + message: 'Heavy rain expected in the next 6 hours', + }, + { severity: 'low', type: 'Wind', message: 'Strong winds possible this evening' }, + ]; + + const hasAlerts = Math.random() > 0.5; + + if (!hasAlerts) { + return { + content: [ + { + type: 'text', + text: `No active weather alerts for ${region}`, + }, + ], + }; + } + + const alertText = alerts + .map(alert => `[${alert.severity.toUpperCase()}] ${alert.type}: ${alert.message}`) + .join('\n'); + + return { + content: [ + { + type: 'text', + text: `Weather Alerts for ${region}:\n\n${alertText}`, + }, + ], + }; + }, +}; diff --git a/examples/server3001/src/tools/get-forecast.ts b/examples/server3001/src/tools/get-forecast.ts new file mode 100644 index 00000000..2808e8dc --- /dev/null +++ b/examples/server3001/src/tools/get-forecast.ts @@ -0,0 +1,58 @@ +/** + * Get 5-day weather forecast + * Requires mcp:read scope + */ +export const getForecast = { + name: 'get-forecast', + description: + 'Get 5-day weather forecast for a city (requires authentication with mcp:read scope)', + inputSchema: { + type: 'object', + properties: { + city: { + type: 'string', + description: 'City name', + }, + days: { + type: 'number', + description: 'Number of days to forecast (1-5)', + default: 5, + }, + }, + required: ['city'], + }, + handler: async (request: any) => { + const { city, days = 5 } = request.params; + + // In a real app, you'd check authentication here + // For now, just return mock data + + const forecastDays = Math.min(days, 5); + const forecast = []; + + for (let i = 0; i < forecastDays; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + + forecast.push({ + date: date.toLocaleDateString(), + temp_high: Math.floor(Math.random() * 10) + 20, + temp_low: Math.floor(Math.random() * 10) + 10, + condition: ['Sunny', 'Cloudy', 'Rainy', 'Partly Cloudy'][Math.floor(Math.random() * 4)], + }); + } + + const forecastText = forecast + .map(day => `${day.date}: ${day.temp_low}-${day.temp_high}°C, ${day.condition}`) + .join('\n'); + + return { + content: [ + { + type: 'text', + text: `${forecastDays}-day forecast for ${city}:\n\n${forecastText}`, + }, + ], + }; + }, +}; diff --git a/examples/server3001/src/tools/get-weather.ts b/examples/server3001/src/tools/get-weather.ts new file mode 100644 index 00000000..4e1ea376 --- /dev/null +++ b/examples/server3001/src/tools/get-weather.ts @@ -0,0 +1,45 @@ +/** + * Get current weather for a city + * No authentication required - public tool + */ +export const getWeather = { + name: 'get-weather', + description: 'Get current weather information for a specific city', + inputSchema: { + type: 'object', + properties: { + city: { + type: 'string', + description: 'City name (e.g., "London", "New York", "Tokyo")', + }, + }, + required: ['city'], + }, + handler: async (request: any) => { + const { city } = request.params; + + // Simulate weather data (in a real app, you'd call a weather API) + const weatherData = { + London: { temp: 15, condition: 'Cloudy', humidity: 75 }, + 'New York': { temp: 22, condition: 'Sunny', humidity: 60 }, + Tokyo: { temp: 18, condition: 'Rainy', humidity: 80 }, + Paris: { temp: 17, condition: 'Partly Cloudy', humidity: 70 }, + Sydney: { temp: 25, condition: 'Sunny', humidity: 55 }, + }; + + const weather = weatherData[city as keyof typeof weatherData] || { + temp: Math.floor(Math.random() * 30) + 10, + condition: ['Sunny', 'Cloudy', 'Rainy', 'Partly Cloudy'][Math.floor(Math.random() * 4)], + humidity: Math.floor(Math.random() * 40) + 50, + }; + + return { + content: [ + { + type: 'text', + text: `Weather in ${city}:\nTemperature: ${weather.temp}°C\nCondition: ${weather.condition}\nHumidity: ${weather.humidity}%`, + }, + ], + }; + }, +}; diff --git a/examples/server3001/start-mcp-server.sh b/examples/server3001/start-mcp-server.sh new file mode 100755 index 00000000..5ac0cf6a --- /dev/null +++ b/examples/server3001/start-mcp-server.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Start MCP Server (Weather Tools) + +set -e + +DIR_CURR=$(cd "$(dirname "$0")";pwd) +cd $DIR_CURR + +# Stop existing server on port 3001 +lsof -i :3001 | grep LISTEN | awk '{print $2}' | xargs kill -9 2>/dev/null || true +sleep 1 + +# Install dependencies if needed +if [ ! -d "node_modules" ]; then + npm install +fi + +echo "🚀 Starting MCP Server on port 3001..." +echo " 📡 MCP Endpoint: http://127.0.0.1:3001/mcp" +echo " 💚 Health: http://127.0.0.1:3001/health" +echo "" + +npm run dev diff --git a/examples/server3001/tsconfig.json b/examples/server3001/tsconfig.json new file mode 100644 index 00000000..7f748950 --- /dev/null +++ b/examples/server3001/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*", "sdk/src/**/*"], + "exclude": ["node_modules", "dist", "src/examples"] +} diff --git a/examples/server3002/README.md b/examples/server3002/README.md new file mode 100644 index 00000000..46fe9266 --- /dev/null +++ b/examples/server3002/README.md @@ -0,0 +1,20 @@ +# MCP Server 3002 + +Simple MCP server with utility tools. + +## Tools + +- `get-time` - Get current time for a timezone or city +- `generate-password` - Generate a secure password + +## Quick Start + +```bash +npm install +npm run dev +``` + +## Endpoints + +- MCP: http://127.0.0.1:3002/mcp +- Health: http://127.0.0.1:3002/health diff --git a/examples/server3002/package-lock.json b/examples/server3002/package-lock.json new file mode 100644 index 00000000..bad6cec4 --- /dev/null +++ b/examples/server3002/package-lock.json @@ -0,0 +1,1644 @@ +{ + "name": "mcp-server-3002", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-server-3002", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "body-parser": "^2.2.0", + "cors": "^2.8.5", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.5", + "@types/node": "^20.11.5", + "tsx": "^4.7.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "../../mcp-cpp-sdk/sdk/typescript": { + "name": "@mcp/filter-sdk", + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.0", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "jose": "^5.10.0", + "koffi": "^2.13.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.4", + "@types/jest": "^29.5.14", + "@types/node": "^18.19.123", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.0.0", + "jest": "^29.0.0", + "prettier": "^3.6.2", + "ts-jest": "^29.0.0", + "tsx": "^4.20.6", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0 <19.0.0" + } + }, + "../gopher-auth-sdk-nodejs": { + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "axios": "^1.6.5", + "express": "^5.1.0", + "jose": "^5.2.0" + }, + "devDependencies": { + "@types/express": "^5.0.5", + "@types/jest": "^29.5.11", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "prettier": "^3.2.4", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "sdk": { + "name": "@mcp/filter-sdk", + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.0", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "jose": "^5.10.0", + "koffi": "^2.13.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.4", + "@types/jest": "^29.5.14", + "@types/node": "^18.19.123", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.0.0", + "jest": "^29.0.0", + "prettier": "^3.6.2", + "ts-jest": "^29.0.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16.0.0 <19.0.0" + } + } + } +} diff --git a/examples/server3002/package.json b/examples/server3002/package.json new file mode 100644 index 00000000..ef13ccff --- /dev/null +++ b/examples/server3002/package.json @@ -0,0 +1,35 @@ +{ + "name": "mcp-server-3002", + "version": "1.0.0", + "description": "Simple MCP server (time and password tools)", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "start": "node dist/src/index.js", + "lint": "eslint src --ext .ts", + "format": "prettier --write \"src/**/*.ts\"" + }, + "keywords": [ + "mcp", + "model-context-protocol" + ], + "author": "", + "license": "MIT", + "dependencies": { + "body-parser": "^2.2.0", + "cors": "^2.8.5", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.5", + "@types/node": "^20.11.5", + "tsx": "^4.7.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/server3002/src/index.ts b/examples/server3002/src/index.ts new file mode 100644 index 00000000..31ec8b2e --- /dev/null +++ b/examples/server3002/src/index.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +/** + * Simple MCP Server (No Authentication) + */ + +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import bodyParser from 'body-parser'; + +import { getTime } from './tools/get-time.js'; +import { generatePassword } from './tools/generate-password.js'; + +const SERVER_PORT = parseInt(process.env.SERVER_PORT || '3002', 10); +const SERVER_URL = process.env.SERVER_URL || `http://127.0.0.1:${SERVER_PORT}`; +const SERVER_NAME = process.env.SERVER_NAME || 'mcp-server-3002'; +const SERVER_VERSION = process.env.SERVER_VERSION || '1.0.0'; + +const TOOLS = [ + { + name: 'get-time', + description: 'Get current time for a timezone or city', + inputSchema: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'Timezone (e.g., "UTC", "America/New_York") or city name', + }, + }, + required: ['location'], + }, + }, + { + name: 'generate-password', + description: 'Generate a secure password', + inputSchema: { + type: 'object', + properties: { + length: { type: 'number', description: 'Length (8-128)', minimum: 8, maximum: 128 }, + includeUppercase: { type: 'boolean', description: 'Include A-Z' }, + includeLowercase: { type: 'boolean', description: 'Include a-z' }, + includeNumbers: { type: 'boolean', description: 'Include 0-9' }, + includeSymbols: { type: 'boolean', description: 'Include symbols' }, + }, + required: [], + }, + }, +]; + +async function startServer() { + const app = express(); + + app.use(cors({ origin: true, credentials: true })); + app.use(bodyParser.json()); + + // Health check + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'healthy', timestamp: new Date().toISOString() }); + }); + + // MCP endpoint + app.all('/mcp', async (req: Request, res: Response) => { + const { method, params, id } = req.body || {}; + let response: any; + + switch (method) { + case 'initialize': + response = { + jsonrpc: '2.0', + result: { + protocolVersion: params?.protocolVersion || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }, + id, + }; + break; + + case 'tools/list': + response = { jsonrpc: '2.0', result: { tools: TOOLS }, id }; + break; + + case 'tools/call': + try { + const toolName = params?.name; + let result: any; + + switch (toolName) { + case 'get-time': + result = await getTime.handler(req.body); + break; + case 'generate-password': + result = await generatePassword.handler(req.body); + break; + default: + result = { + content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], + isError: true, + }; + } + response = { jsonrpc: '2.0', result, id }; + } catch (error) { + response = { + jsonrpc: '2.0', + error: { code: -32603, message: error instanceof Error ? error.message : 'Error' }, + id, + }; + } + break; + + default: + response = { + jsonrpc: '2.0', + error: { code: -32601, message: `Method not found: ${method}` }, + id, + }; + } + + res.json(response); + }); + + app.listen(SERVER_PORT, '127.0.0.1', () => { + console.log(`MCP Server running at ${SERVER_URL}`); + console.log(` POST ${SERVER_URL}/mcp`); + console.log(` GET ${SERVER_URL}/health`); + }); + + process.on('SIGINT', () => { + console.log('\nShutting down...'); + process.exit(0); + }); +} + +startServer().catch(error => { + console.error('Failed to start server:', error); + process.exit(1); +}); diff --git a/examples/server3002/src/tools/generate-password.ts b/examples/server3002/src/tools/generate-password.ts new file mode 100644 index 00000000..5315b081 --- /dev/null +++ b/examples/server3002/src/tools/generate-password.ts @@ -0,0 +1,196 @@ +/** + * Generate a secure password with customizable options + * No authentication required - public tool + */ +export const generatePassword = { + name: 'generate-password', + description: 'Generate a secure password with customizable length and character sets', + inputSchema: { + type: 'object', + properties: { + length: { + type: 'number', + description: 'Length of the password (8-128 characters)', + minimum: 8, + maximum: 128, + default: 16, + }, + includeUppercase: { + type: 'boolean', + description: 'Include uppercase letters (A-Z)', + default: true, + }, + includeLowercase: { + type: 'boolean', + description: 'Include lowercase letters (a-z)', + default: true, + }, + includeNumbers: { + type: 'boolean', + description: 'Include numbers (0-9)', + default: true, + }, + includeSymbols: { + type: 'boolean', + description: 'Include symbols (!@#$%^&*)', + default: true, + }, + excludeSimilar: { + type: 'boolean', + description: 'Exclude similar looking characters (0,O,l,1,I)', + default: false, + }, + }, + required: [], + }, + handler: async (request: any) => { + // Handle different parameter structures + let params = {}; + + if (request.params?.arguments) { + if (typeof request.params.arguments === 'string') { + // Gopher-orch sends arguments as JSON string + try { + params = JSON.parse(request.params.arguments); + } catch (e) { + params = {}; + } + } else { + // Direct calls send arguments as object + params = request.params.arguments; + } + } else { + // Fallback to direct params + params = request.params || {}; + } + + const { + length = 16, + includeUppercase = true, + includeLowercase = true, + includeNumbers = true, + includeSymbols = true, + excludeSimilar = false, + } = params; + + // Validate length + if (length < 8 || length > 128) { + return { + content: [ + { + type: 'text', + text: 'Error: Password length must be between 8 and 128 characters.', + }, + ], + isError: true, + }; + } + + // Character sets + let uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + let lowercase = 'abcdefghijklmnopqrstuvwxyz'; + let numbers = '0123456789'; + let symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + + // Exclude similar characters if requested + if (excludeSimilar) { + uppercase = uppercase.replace(/[O]/g, ''); + lowercase = lowercase.replace(/[l]/g, ''); + numbers = numbers.replace(/[01]/g, ''); + symbols = symbols.replace(/[|]/g, ''); + } + + // Build character pool + let charPool = ''; + let requiredChars: string[] = []; + + if (includeUppercase) { + charPool += uppercase; + requiredChars.push(uppercase[Math.floor(Math.random() * uppercase.length)]); + } + if (includeLowercase) { + charPool += lowercase; + requiredChars.push(lowercase[Math.floor(Math.random() * lowercase.length)]); + } + if (includeNumbers) { + charPool += numbers; + requiredChars.push(numbers[Math.floor(Math.random() * numbers.length)]); + } + if (includeSymbols) { + charPool += symbols; + requiredChars.push(symbols[Math.floor(Math.random() * symbols.length)]); + } + + // Ensure at least one character set is selected + if (charPool.length === 0) { + return { + content: [ + { + type: 'text', + text: 'Error: At least one character set must be included.', + }, + ], + isError: true, + }; + } + + // Generate password + let password = ''; + + // First, add required characters to ensure all selected types are present + for (const char of requiredChars) { + password += char; + } + + // Fill the rest with random characters + for (let i = password.length; i < length; i++) { + password += charPool[Math.floor(Math.random() * charPool.length)]; + } + + // Shuffle the password to randomize the position of required characters + password = password + .split('') + .sort(() => Math.random() - 0.5) + .join(''); + + // Calculate password strength + let strength = 0; + let strengthText = ''; + + if (includeUppercase) strength += 26; + if (includeLowercase) strength += 26; + if (includeNumbers) strength += 10; + if (includeSymbols) strength += symbols.length; + + const entropy = Math.log2(Math.pow(strength, length)); + + if (entropy < 40) { + strengthText = 'Weak'; + } else if (entropy < 60) { + strengthText = 'Fair'; + } else if (entropy < 80) { + strengthText = 'Good'; + } else if (entropy < 100) { + strengthText = 'Strong'; + } else { + strengthText = 'Very Strong'; + } + + // Build settings summary + const settings: string[] = []; + if (includeUppercase) settings.push('Uppercase'); + if (includeLowercase) settings.push('Lowercase'); + if (includeNumbers) settings.push('Numbers'); + if (includeSymbols) settings.push('Symbols'); + if (excludeSimilar) settings.push('No Similar Chars'); + + return { + content: [ + { + type: 'text', + text: `Generated Password:\n\n🔐 ${password}\n\nSettings:\n• Length: ${length} characters\n• Character types: ${settings.join(', ')}\n• Strength: ${strengthText}\n• Entropy: ${entropy.toFixed(1)} bits\n\n💡 Tip: Store this password securely and don't reuse it for multiple accounts.`, + }, + ], + }; + }, +}; diff --git a/examples/server3002/src/tools/get-time.ts b/examples/server3002/src/tools/get-time.ts new file mode 100644 index 00000000..160e16bf --- /dev/null +++ b/examples/server3002/src/tools/get-time.ts @@ -0,0 +1,130 @@ +/** + * Get current time for a timezone or location + * No authentication required - public tool + */ +export const getTime = { + name: 'get-time', + description: 'Get current time and date for a specific timezone or city', + inputSchema: { + type: 'object', + properties: { + location: { + type: 'string', + description: + 'Timezone (e.g., "UTC", "America/New_York") or city name (e.g., "London", "Tokyo")', + }, + }, + required: ['location'], + }, + handler: async (request: any) => { + // Handle different parameter structures + let location; + + if (request.params?.arguments) { + if (typeof request.params.arguments === 'string') { + // Gopher-orch sends arguments as JSON string + try { + const parsedArgs = JSON.parse(request.params.arguments); + location = parsedArgs.location; + } catch (e) { + location = undefined; + } + } else { + // Direct calls send arguments as object + location = request.params.arguments.location; + } + } else { + // Fallback to direct params + location = request.params?.location; + } + + if (!location) { + return { + content: [ + { + type: 'text', + text: 'Error: No location parameter provided', + }, + ], + isError: true, + }; + } + + // Map common city names to timezones + const cityToTimezone: { [key: string]: string } = { + london: 'Europe/London', + 'new york': 'America/New_York', + tokyo: 'Asia/Tokyo', + paris: 'Europe/Paris', + france: 'Europe/Paris', + sydney: 'Australia/Sydney', + 'los angeles': 'America/Los_Angeles', + chicago: 'America/Chicago', + dubai: 'Asia/Dubai', + singapore: 'Asia/Singapore', + mumbai: 'Asia/Kolkata', + beijing: 'Asia/Shanghai', + moscow: 'Europe/Moscow', + berlin: 'Europe/Berlin', + toronto: 'America/Toronto', + }; + + // Determine timezone + let timezone = location; + const normalizedLocation = location.toLowerCase(); + + if (cityToTimezone[normalizedLocation]) { + timezone = cityToTimezone[normalizedLocation]; + } + + try { + // Get current time in specified timezone + const now = new Date(); + const timeInTimezone = now.toLocaleString('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + }); + + const dateOnly = now.toLocaleDateString('en-US', { + timeZone: timezone, + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const timeOnly = now.toLocaleTimeString('en-US', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + }); + + return { + content: [ + { + type: 'text', + text: `Current time in ${location}:\n\nFull Date & Time: ${timeInTimezone}\nDate: ${dateOnly}\nTime: ${timeOnly}\nTimezone: ${timezone}`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error: Invalid timezone or location "${location}". Please use a valid timezone (e.g., "UTC", "America/New_York") or city name (e.g., "London", "Tokyo").`, + }, + ], + isError: true, + }; + } + }, +}; diff --git a/examples/server3002/start-mcp-server.sh b/examples/server3002/start-mcp-server.sh new file mode 100755 index 00000000..3e00f8ad --- /dev/null +++ b/examples/server3002/start-mcp-server.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Start MCP Server (Time & Password Tools) + +set -e + +DIR_CURR=$(cd "$(dirname "$0")";pwd) +cd $DIR_CURR + +# Stop existing server on port 3002 +lsof -i :3002 | grep LISTEN | awk '{print $2}' | xargs kill -9 2>/dev/null || true +sleep 1 + +# Install dependencies if needed +if [ ! -d "node_modules" ]; then + npm install +fi + +echo "🚀 Starting MCP Server on port 3002..." +echo " 📡 MCP Endpoint: http://127.0.0.1:3002/mcp" +echo " 💚 Health: http://127.0.0.1:3002/health" +echo "" + +npm run dev diff --git a/examples/server3002/tsconfig.json b/examples/server3002/tsconfig.json new file mode 100644 index 00000000..7f748950 --- /dev/null +++ b/examples/server3002/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*", "sdk/src/**/*"], + "exclude": ["node_modules", "dist", "src/examples"] +} From af362eb7c3f10fed5a962ecab2a3994ef7295a44 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 12:49:51 +0800 Subject: [PATCH 05/11] Add FFI verification tests for Ruby SDK RSpec tests for verifying FFI bindings work correctly: - spec/gopher_orch_spec.rb: Tests for main GopherOrch module - spec/native_spec.rb: Tests for Native FFI bindings - spec/config_builder_spec.rb: Unit tests for ConfigBuilder - spec/agent_result_spec.rb: Unit tests for AgentResult - spec/agent_result_status_spec.rb: Unit tests for AgentResultStatus Test features: - :requires_native tag to skip tests when native library unavailable - Tests FFI function calls work without crashes - Tests builder pattern and data structures - Tests error handling --- .rspec | 3 + spec/agent_result_spec.rb | 40 +++++++++++++ spec/agent_result_status_spec.rb | 60 +++++++++++++++++++ spec/config_builder_spec.rb | 69 +++++++++++++++++++++ spec/gopher_orch_spec.rb | 68 +++++++++++++++++++++ spec/native_spec.rb | 100 +++++++++++++++++++++++++++++++ spec/spec_helper.rb | 20 +++++++ 7 files changed, 360 insertions(+) create mode 100644 .rspec create mode 100644 spec/agent_result_spec.rb create mode 100644 spec/agent_result_status_spec.rb create mode 100644 spec/config_builder_spec.rb create mode 100644 spec/gopher_orch_spec.rb create mode 100644 spec/native_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..34c5164d --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/spec/agent_result_spec.rb b/spec/agent_result_spec.rb new file mode 100644 index 00000000..86b5ed75 --- /dev/null +++ b/spec/agent_result_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.describe GopherOrch::AgentResult do + describe '.success' do + it 'creates a successful result' do + result = GopherOrch::AgentResult.success('Hello, world!') + + expect(result.response).to eq('Hello, world!') + expect(result.status.success?).to be true + expect(result.success?).to be true + expect(result.error_message).to be_nil + expect(result.iteration_count).to eq(1) + expect(result.tokens_used).to eq(0) + end + end + + describe '.error' do + it 'creates an error result' do + result = GopherOrch::AgentResult.error('Something went wrong') + + expect(result.response).to eq('') + expect(result.status.success?).to be false + expect(result.success?).to be false + expect(result.error_message).to eq('Something went wrong') + expect(result.iteration_count).to eq(0) + end + end + + describe '.timeout' do + it 'creates a timeout result' do + result = GopherOrch::AgentResult.timeout('Operation timed out') + + expect(result.response).to eq('') + expect(result.status.success?).to be false + expect(result.success?).to be false + expect(result.error_message).to eq('Operation timed out') + expect(result.status.value).to eq(GopherOrch::AgentResultStatus::TIMEOUT) + end + end +end diff --git a/spec/agent_result_status_spec.rb b/spec/agent_result_status_spec.rb new file mode 100644 index 00000000..2c8932f4 --- /dev/null +++ b/spec/agent_result_status_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.describe GopherOrch::AgentResultStatus do + describe '.success' do + it 'creates a success status' do + status = GopherOrch::AgentResultStatus.success + expect(status.value).to eq(GopherOrch::AgentResultStatus::SUCCESS) + expect(status.success?).to be true + end + end + + describe '.error' do + it 'creates an error status' do + status = GopherOrch::AgentResultStatus.error + expect(status.value).to eq(GopherOrch::AgentResultStatus::ERROR) + expect(status.success?).to be false + end + end + + describe '.timeout' do + it 'creates a timeout status' do + status = GopherOrch::AgentResultStatus.timeout + expect(status.value).to eq(GopherOrch::AgentResultStatus::TIMEOUT) + expect(status.success?).to be false + end + end + + describe '.max_iterations_reached' do + it 'creates a max iterations reached status' do + status = GopherOrch::AgentResultStatus.max_iterations_reached + expect(status.value).to eq(GopherOrch::AgentResultStatus::MAX_ITERATIONS_REACHED) + expect(status.success?).to be false + end + end + + describe '#to_s' do + it 'returns the status value' do + status = GopherOrch::AgentResultStatus.success + expect(status.to_s).to eq('SUCCESS') + end + end + + describe '#==' do + it 'compares with another AgentResultStatus' do + status1 = GopherOrch::AgentResultStatus.success + status2 = GopherOrch::AgentResultStatus.success + status3 = GopherOrch::AgentResultStatus.error + + expect(status1).to eq(status2) + expect(status1).not_to eq(status3) + end + + it 'compares with a String' do + status = GopherOrch::AgentResultStatus.success + + expect(status).to eq('SUCCESS') + expect(status).not_to eq('ERROR') + end + end +end diff --git a/spec/config_builder_spec.rb b/spec/config_builder_spec.rb new file mode 100644 index 00000000..be093743 --- /dev/null +++ b/spec/config_builder_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +RSpec.describe GopherOrch::ConfigBuilder do + describe '.create' do + it 'returns a new ConfigBuilder' do + expect(GopherOrch::ConfigBuilder.create).to be_a(GopherOrch::ConfigBuilder) + end + end + + describe '#build' do + context 'with API key' do + it 'creates a config with API key' do + config = GopherOrch::ConfigBuilder.create + .with_provider('TestProvider') + .with_model('test-model') + .with_api_key('test-key') + .build + + expect(config.provider).to eq('TestProvider') + expect(config.model).to eq('test-model') + expect(config.api_key).to eq('test-key') + expect(config.api_key?).to be true + expect(config.server_config?).to be false + end + end + + context 'with server config' do + it 'creates a config with server config' do + server_config = '{"servers": []}' + + config = GopherOrch::ConfigBuilder.create + .with_provider('TestProvider') + .with_model('test-model') + .with_server_config(server_config) + .build + + expect(config.provider).to eq('TestProvider') + expect(config.model).to eq('test-model') + expect(config.server_config).to eq(server_config) + expect(config.api_key?).to be false + expect(config.server_config?).to be true + end + end + + context 'with empty values' do + it 'creates a config with default values' do + config = GopherOrch::ConfigBuilder.create.build + + expect(config.provider).to eq('') + expect(config.model).to eq('') + expect(config.api_key).to be_nil + expect(config.server_config).to be_nil + expect(config.api_key?).to be false + expect(config.server_config?).to be false + end + end + end + + describe 'fluent interface' do + it 'returns self from all builder methods' do + builder = GopherOrch::ConfigBuilder.create + + expect(builder.with_provider('Test')).to eq(builder) + expect(builder.with_model('test')).to eq(builder) + expect(builder.with_api_key('key')).to eq(builder) + expect(builder.with_server_config('{}')).to eq(builder) + end + end +end diff --git a/spec/gopher_orch_spec.rb b/spec/gopher_orch_spec.rb new file mode 100644 index 00000000..0254ea41 --- /dev/null +++ b/spec/gopher_orch_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe GopherOrch do + describe '.VERSION' do + it 'has a version number' do + expect(GopherOrch::VERSION).not_to be_nil + expect(GopherOrch::VERSION).to match(/\d+\.\d+\.\d+/) + end + end + + describe '.available?', :requires_native do + it 'returns true when native library is available' do + expect(GopherOrch.available?).to be true + end + end + + describe '.init!' do + context 'when native library is not available' do + before do + # Skip if library is actually available + skip 'Library is available' if GopherOrch.available? + end + + it 'raises LibraryError' do + expect { GopherOrch.init! }.to raise_error(GopherOrch::LibraryError) + end + end + end + + describe '.initialized?', :requires_native do + it 'returns true after init!' do + GopherOrch.init! + expect(GopherOrch.initialized?).to be true + end + end + + describe '.shutdown', :requires_native do + it 'sets initialized to false' do + GopherOrch.init! + expect(GopherOrch.initialized?).to be true + + GopherOrch.shutdown + expect(GopherOrch.initialized?).to be false + end + + it 'allows re-initialization' do + GopherOrch.init! + GopherOrch.shutdown + GopherOrch.init! + expect(GopherOrch.initialized?).to be true + end + end + + describe '.last_error', :requires_native do + it 'returns a string or nil' do + GopherOrch.init! + error = GopherOrch.last_error + expect(error).to be_nil.or be_a(String) + end + end + + describe '.clear_error', :requires_native do + it 'does not raise an error' do + GopherOrch.init! + expect { GopherOrch.clear_error }.not_to raise_error + end + end +end diff --git a/spec/native_spec.rb b/spec/native_spec.rb new file mode 100644 index 00000000..f313c336 --- /dev/null +++ b/spec/native_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +RSpec.describe GopherOrch::Native do + SERVER_CONFIG = <<~JSON + { + "succeeded": true, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1", + "name": "test-server", + "transport": "http_sse", + "config": {"url": "http://127.0.0.1:9999/mcp", "headers": {}}, + "connectTimeout": 5000, + "requestTimeout": 30000 + } + ] + } + } + JSON + + describe '.init!', :requires_native do + it 'initializes successfully' do + expect { GopherOrch::Native.init! }.not_to raise_error + expect(GopherOrch::Native.initialized?).to be true + end + end + + describe '.library_path', :requires_native do + it 'returns the library path after initialization' do + GopherOrch::Native.init! + expect(GopherOrch::Native.library_path).to be_a(String) + expect(GopherOrch::Native.library_path).not_to be_empty + end + end + + describe '.agent_create_by_json', :requires_native do + it 'creates an agent with valid config' do + handle = GopherOrch::Native.agent_create_by_json( + 'AnthropicProvider', + 'claude-3-haiku-20240307', + SERVER_CONFIG + ) + + # Agent may be nil if no API key, but function should not crash + GopherOrch::Native.agent_release(handle) if handle && !handle.null? + + # Test passed if we got here without exception + expect(true).to be true + end + + it 'handles empty config gracefully' do + handle = GopherOrch::Native.agent_create_by_json( + 'AnthropicProvider', + 'claude-3-haiku-20240307', + '{}' + ) + + # Should handle gracefully + GopherOrch::Native.agent_release(handle) if handle && !handle.null? + + # Test passed if we got here without exception + expect(true).to be true + end + end + + describe '.agent_create_by_api_key', :requires_native do + it 'handles API key creation' do + handle = GopherOrch::Native.agent_create_by_api_key( + 'AnthropicProvider', + 'claude-3-haiku-20240307', + 'test-api-key-12345' + ) + + # May return nil if API key is invalid, but should not crash + GopherOrch::Native.agent_release(handle) if handle && !handle.null? + + # Test passed if we got here without exception + expect(true).to be true + end + end + + describe '.last_error', :requires_native do + it 'returns a string or nil' do + GopherOrch::Native.init! + error = GopherOrch::Native.last_error + expect(error).to be_nil.or be_a(String) + end + end + + describe '.clear_error', :requires_native do + it 'does not raise an error' do + GopherOrch::Native.init! + expect { GopherOrch::Native.clear_error }.not_to raise_error + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..f9395e1e --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'gopher_orch' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + # Skip tests that require native library if not available + config.before(:each, :requires_native) do + skip 'Native library not available. Run ./build.sh first.' unless GopherOrch.available? + end +end From a09d389a517bf9274c566bc53eeaf20d84534499 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 12:51:33 +0800 Subject: [PATCH 06/11] Add README.md documentation for Ruby SDK Comprehensive documentation including: - Feature overview and architecture diagram - Installation options (RubyGems, Gemfile, source) - Building from source guide with prerequisites - Native library details and search paths - API documentation with code examples - Example usage with local MCP servers - Project structure and development guide - Troubleshooting common issues - Contributing guidelines --- README.md | 660 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..1eb635d8 --- /dev/null +++ b/README.md @@ -0,0 +1,660 @@ +# gopher-orch - Ruby SDK + +Ruby SDK for Gopher Orch - AI Agent orchestration framework with native C++ performance. + +## Table of Contents + +- [Features](#features) +- [When to Use This SDK](#when-to-use-this-sdk) +- [Architecture](#architecture) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Building from Source](#building-from-source) + - [Prerequisites](#prerequisites) + - [Step 1: Clone the Repository](#step-1-clone-the-repository) + - [Step 2: Build Everything](#step-2-build-everything) + - [Step 3: Verify the Build](#step-3-verify-the-build) + - [Step 4: Run Tests](#step-4-run-tests) +- [Native Library Details](#native-library-details) + - [Library Location](#library-location) + - [Platform-Specific Library Names](#platform-specific-library-names) + - [Library Search Order](#library-search-order) +- [API Documentation](#api-documentation) + - [GopherOrch::Agent](#gopherorcagent) + - [GopherOrch::ConfigBuilder](#gopherorchconfigbuilder) + - [Error Handling](#error-handling) +- [Examples](#examples) + - [Basic Usage with API Key](#basic-usage-with-api-key) + - [Using Local MCP Servers](#using-local-mcp-servers) + - [Running the Example](#running-the-example) +- [Development](#development) + - [Project Structure](#project-structure) + - [Build Scripts](#build-scripts) + - [Rebuilding Native Library](#rebuilding-native-library) + - [Updating Submodules](#updating-submodules) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [License](#license) +- [Links](#links) +- [Acknowledgments](#acknowledgments) + +--- + +## Features + +- **Native Performance** - Powered by C++ core with Ruby bindings via FFI +- **AI Agent Framework** - Build intelligent agents with LLM integration +- **MCP Protocol** - Model Context Protocol client and server support +- **Tool Orchestration** - Manage and execute tools across multiple MCP servers +- **State Management** - Built-in state graph for complex workflows +- **Idiomatic Ruby** - Clean, Ruby-style API with builder pattern + +## When to Use This SDK + +This SDK is ideal for: + +- **Rails applications** that need high-performance AI agent orchestration +- **Sinatra/Grape services** requiring MCP protocol support +- **Ruby CLI tools** integrating AI agents +- **Background jobs** (Sidekiq, Resque) needing reliable agent infrastructure +- **API backends** built with Ruby + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Your Application │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Ruby SDK (GopherOrch) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Agent │ │ConfigBuilder│ │ Error Classes │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ FFI (ffi gem) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Native Library (libgopher-orch) │ +│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │ +│ │ Agent Engine │ │ LLM Providers │ │ MCP Client │ │ +│ │ │ │ - Anthropic │ │ - HTTP/SSE │ │ +│ │ │ │ - OpenAI │ │ - Tool Registry │ │ +│ └───────────────┘ └───────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MCP Servers │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Weather API │ │ Database │ │ Custom Tools │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Installation + +### Option 1: RubyGems (when published) + +```bash +gem install gopher_orch +``` + +Or add to your Gemfile: + +```ruby +gem 'gopher_orch' +``` + +### Option 2: Git Repository + +```ruby +# Gemfile +gem 'gopher_orch', git: 'https://github.com/GopherSecurity/gopher-mcp-ruby.git' +``` + +### Option 3: Build from Source + +See [Building from Source](#building-from-source) section below. + +## Quick Start + +```ruby +require 'gopher_orch' + +# Create an agent with API key (fetches server config from remote API) +config = GopherOrch::ConfigBuilder.create + .with_provider('AnthropicProvider') + .with_model('claude-3-haiku-20240307') + .with_api_key('your-api-key') + .build + +agent = GopherOrch::Agent.create(config) + +# Run the agent +result = agent.run('What is the weather in Tokyo?') +puts result + +# Cleanup (optional - happens automatically) +agent.dispose +``` + +--- + +## Building from Source + +This SDK wraps a native C++ library via Ruby FFI. You must build the native library before using the SDK. + +### Prerequisites + +| Requirement | Version | Notes | +|-------------|---------|-------| +| Ruby | >= 2.7 | With FFI gem support | +| Bundler | Latest | Dependency manager | +| Git | Latest | For cloning and submodules | +| CMake | >= 3.15 | Native library build system | +| C++ Compiler | C++14+ | Clang (macOS), GCC (Linux), MSVC (Windows) | + +**Platform-specific requirements:** + +- **macOS**: Xcode Command Line Tools (`xcode-select --install`) +- **Linux**: `build-essential`, `libssl-dev`, `ruby-dev` +- **Windows**: Visual Studio 2019+ with C++ workload + +### Step 1: Clone the Repository + +```bash +git clone https://github.com/GopherSecurity/gopher-mcp-ruby.git +cd gopher-mcp-ruby +``` + +### Step 2: Build Everything + +**Using build.sh (recommended)** + +The `build.sh` script handles everything automatically: + +```bash +./build.sh +``` + +**Using build.sh with Multiple GitHub Accounts:** + +If you have multiple GitHub accounts configured with SSH host aliases, use the `GITHUB_SSH_HOST` environment variable: + +```bash +# Use custom SSH host alias for cloning private submodules +GITHUB_SSH_HOST=your-ssh-alias ./build.sh + +# Example: if your ~/.ssh/config has "Host github-work" for work account +GITHUB_SSH_HOST=github-work ./build.sh +``` + +**What happens during build:** + +1. **Submodule update** - Initializes and updates submodules (with SSH URL rewriting if `GITHUB_SSH_HOST` is set) +2. **CMake configure** - Configures the C++ build with Release settings +3. **Native compilation** - Compiles C++ to shared libraries +4. **Library installation** - Copies libraries to `native/lib/` +5. **Dependency copying** - Copies required dependencies (gopher-mcp, fmt) +6. **macOS fixes** - Fixes dylib install names for proper FFI loading +7. **Bundler install** - Installs Ruby dependencies +8. **RSpec tests** - Runs test suite + +### Step 3: Verify the Build + +```bash +# Check native libraries were built +ls -la native/lib/ + +# Expected output (macOS): +# libgopher-orch.dylib +# libgopher-mcp.dylib +# libgopher-mcp-event.dylib +# libfmt.dylib + +# Verify Ruby can load the SDK +ruby -r./lib/gopher_orch -e "puts GopherOrch.available? ? 'OK' : 'FAIL'" +``` + +### Step 4: Run Tests + +```bash +bundle exec rspec +``` + +--- + +## Native Library Details + +### Library Location + +After building, native libraries are installed to: + +``` +native/ +├── lib/ # Shared libraries +│ ├── libgopher-orch.dylib # Main orchestration library (macOS) +│ ├── libgopher-orch.so # Main orchestration library (Linux) +│ ├── libgopher-mcp.dylib # MCP protocol library +│ ├── libgopher-mcp-event.dylib # Event handling +│ └── libfmt.dylib # Formatting library +└── include/ # C++ headers (for development) + └── orch/ + └── core/ +``` + +### Platform-Specific Library Names + +| Platform | Library Extension | Example | +|----------|------------------|---------| +| macOS | `.dylib` | `libgopher-orch.dylib` | +| Linux | `.so` | `libgopher-orch.so` | +| Windows | `.dll` | `gopher-orch.dll` | + +### Library Search Order + +The SDK searches for the native library in this order: + +1. `GOPHER_ORCH_LIBRARY_PATH` environment variable +2. `native/lib/` relative to current working directory +3. `native/lib/` relative to the SDK source directory +4. System paths (`/usr/local/lib`, `/opt/homebrew/lib`) + +--- + +## API Documentation + +### GopherOrch::Agent + +The main class for creating and running AI agents: + +```ruby +require 'gopher_orch' + +# Initialize the library (called automatically on first create) +GopherOrch.init! + +# Create with API key (fetches server config from remote API) +config = GopherOrch::ConfigBuilder.create + .with_provider('AnthropicProvider') + .with_model('claude-3-haiku-20240307') + .with_api_key('your-api-key') + .build + +agent = GopherOrch::Agent.create(config) + +# Or create with JSON server config +server_config = <<~JSON + { + "succeeded": true, + "data": { + "servers": [{ + "serverId": "server1", + "name": "My MCP Server", + "transport": "http_sse", + "config": {"url": "http://localhost:3001/mcp"} + }] + } + } +JSON + +agent = GopherOrch::Agent.create_with_server_config( + 'AnthropicProvider', + 'claude-3-haiku-20240307', + server_config +) + +# Run a query +result = agent.run('Your prompt here') + +# Run with custom timeout (default: 60000ms) +result = agent.run_with_timeout('Your prompt here', 30_000) + +# Run with detailed result information +detailed = agent.run_detailed('Your prompt here') +# Returns AgentResult with: response, status, iteration_count, tokens_used + +# Manual cleanup (optional - happens automatically) +agent.dispose + +# Shutdown library +GopherOrch.shutdown +``` + +### GopherOrch::ConfigBuilder + +Builder for creating agent configurations: + +```ruby +require 'gopher_orch' + +# With API key +config = GopherOrch::ConfigBuilder.create + .with_provider('AnthropicProvider') + .with_model('claude-3-haiku-20240307') + .with_api_key('your-api-key') + .build + +# With server config +config = GopherOrch::ConfigBuilder.create + .with_provider('AnthropicProvider') + .with_model('claude-3-haiku-20240307') + .with_server_config('{"succeeded": true, "data": {"servers": []}}') + .build + +# Check configuration +config.api_key? # => true +config.server_config? # => false +``` + +### Error Handling + +The SDK provides typed exceptions for different failure scenarios: + +```ruby +require 'gopher_orch' + +begin + config = GopherOrch::ConfigBuilder.create + .with_provider('AnthropicProvider') + .with_model('claude-3-haiku-20240307') + .with_api_key('invalid-key') + .build + + agent = GopherOrch::Agent.create(config) + result = agent.run('query') +rescue GopherOrch::LibraryError => e + puts "Library not found: #{e.message}" +rescue GopherOrch::ConfigError => e + puts "Invalid config: #{e.message}" +rescue GopherOrch::AgentError => e + puts "Agent error: #{e.message}" +rescue GopherOrch::DisposedError => e + puts "Agent disposed: #{e.message}" +end +``` + +--- + +## Examples + +### Basic Usage with API Key + +```ruby +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'gopher_orch' + +api_key = ENV['GOPHER_API_KEY'] + +config = GopherOrch::ConfigBuilder.create + .with_provider('AnthropicProvider') + .with_model('claude-3-haiku-20240307') + .with_api_key(api_key) + .build + +agent = GopherOrch::Agent.create(config) + +answer = agent.run('What time is it in London?') +puts "Answer: #{answer}" + +agent.dispose +``` + +### Using Local MCP Servers + +```ruby +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'gopher_orch' + +SERVER_CONFIG = <<~JSON + { + "succeeded": true, + "code": 200, + "message": "OK", + "data": { + "servers": [ + { + "version": "1.0.0", + "serverId": "weather-server", + "name": "Weather Service", + "transport": "http_sse", + "config": { + "url": "http://localhost:3001/mcp", + "headers": {} + }, + "connectTimeout": 5000, + "requestTimeout": 30000 + } + ] + } + } +JSON + +config = GopherOrch::ConfigBuilder.create + .with_provider('AnthropicProvider') + .with_model('claude-3-haiku-20240307') + .with_server_config(SERVER_CONFIG) + .build + +agent = GopherOrch::Agent.create(config) + +result = agent.run('What is the weather in New York?') +puts result + +agent.dispose +``` + +### Running the Example + +```bash +# Run with the convenience script (starts servers automatically) +cd examples && ./client_example_json_run.sh + +# Or manually: +# Terminal 1: Start server3001 +cd examples/server3001 && npm install && npm run dev + +# Terminal 2: Start server3002 +cd examples/server3002 && npm install && npm run dev + +# Terminal 3: Run the Ruby client +ANTHROPIC_API_KEY=your-key ruby examples/client_example_json.rb +``` + +--- + +## Development + +### Project Structure + +``` +gopher-mcp-ruby/ +├── lib/ +│ ├── gopher_orch.rb # Main entry point +│ └── gopher_orch/ +│ ├── version.rb # Version constant +│ ├── errors.rb # Exception classes +│ ├── native.rb # FFI bindings +│ ├── config.rb # Configuration class +│ ├── config_builder.rb # Configuration builder +│ ├── agent.rb # Main agent class +│ ├── agent_result.rb # Result class +│ └── agent_result_status.rb # Result status +├── spec/ # RSpec tests +│ ├── spec_helper.rb +│ ├── gopher_orch_spec.rb +│ ├── native_spec.rb +│ ├── config_builder_spec.rb +│ ├── agent_result_spec.rb +│ └── agent_result_status_spec.rb +├── native/ # Native libraries (generated) +│ ├── lib/ # Shared libraries (.dylib, .so, .dll) +│ └── include/ # C++ headers +├── third_party/ # Git submodules +│ └── gopher-orch/ # C++ implementation +├── examples/ # Example code +│ ├── client_example_json.rb +│ ├── client_example_json_run.sh +│ ├── server3001/ # Mock weather MCP server +│ └── server3002/ # Mock tools MCP server +├── build.sh # Build orchestration script +├── Gemfile # Bundler configuration +├── gopher_orch.gemspec # Gem specification +├── Rakefile # Rake tasks +└── README.md +``` + +### Build Scripts + +| Script | Description | +|--------|-------------| +| `./build.sh` | Full build (submodules + native + Bundler) | +| `./build.sh --clean` | Clean CMake cache while preserving _deps | +| `./build.sh --clean --build` | Clean and rebuild | +| `GITHUB_SSH_HOST=alias ./build.sh` | Build with custom SSH host | +| `bundle install` | Install Ruby dependencies | +| `bundle exec rspec` | Run tests | + +### Rebuilding Native Library + +If you modify the C++ code or switch branches: + +```bash +# Clean and rebuild (preserves downloaded dependencies) +./build.sh --clean --build +``` + +### Updating Submodules + +To pull latest changes from native libraries: + +```bash +# Update to latest commit +cd third_party/gopher-orch +git fetch origin +git checkout +cd ../.. + +# Rebuild +./build.sh --clean --build +``` + +--- + +## Troubleshooting + +### "Library not found" Error + +**Cause**: Native library not built or not in expected location. + +**Solution**: +```bash +# Rebuild native library +./build.sh + +# Verify library exists +ls native/lib/libgopher-orch.* +``` + +### "Submodule is empty" Error + +**Cause**: Git submodules not initialized. + +**Solution**: +```bash +git submodule update --init --recursive +``` + +### CMake Configuration Fails + +**Cause**: Missing dependencies or wrong CMake version. + +**Solution**: +```bash +# macOS +brew install cmake + +# Linux (Ubuntu/Debian) +sudo apt-get install cmake build-essential libssl-dev + +# Verify version +cmake --version # Should be >= 3.15 +``` + +### FFI Gem Not Loading + +**Cause**: FFI gem not installed or native dependencies missing. + +**Solution**: + +```bash +# Install bundler dependencies +bundle install + +# Or install FFI manually +gem install ffi +``` + +### "LoadError: cannot load such file" Error + +**Cause**: Bundler not configured. + +**Solution**: +```bash +bundle install +``` + +### Build Fails on Apple Silicon (M1/M2) + +**Cause**: Architecture mismatch. + +**Solution**: +```bash +# Ensure using native arm64 toolchain +arch -arm64 ./build.sh +``` + +--- + +## Contributing + +Contributions are welcome! Please read our contributing guidelines. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Ensure submodules are initialized (`git submodule update --init --recursive`) +4. Make your changes +5. Run tests (`bundle exec rspec`) +6. Commit your changes (`git commit -m 'Add amazing feature'`) +7. Push to the branch (`git push origin feature/amazing-feature`) +8. Open a Pull Request + +--- + +## License + +MIT License - see [LICENSE](LICENSE) file for details. + +## Links + +- [GitHub Repository](https://github.com/GopherSecurity/gopher-mcp-ruby) +- [Java SDK](https://github.com/GopherSecurity/gopher-mcp-java) +- [PHP SDK](https://github.com/GopherSecurity/gopher-mcp-php) +- [Rust SDK](https://github.com/GopherSecurity/gopher-mcp-rust) +- [Python SDK](https://github.com/GopherSecurity/gopher-mcp-python) +- [TypeScript SDK](https://github.com/GopherSecurity/gopher-orch-js) +- [Native C++ Implementation](https://github.com/GopherSecurity/gopher-orch) +- [Model Context Protocol](https://modelcontextprotocol.io/) + +## Acknowledgments + +- Built on [gopher-orch](https://github.com/GopherSecurity/gopher-orch) C++ framework +- Uses [gopher-mcp](https://github.com/GopherSecurity/gopher-mcp) for MCP protocol +- Inspired by LangChain and LangGraph +- FFI bindings via [Ruby FFI](https://github.com/ffi/ffi) From edc28567ea8f06ba8137050ce42a4f1d9215bc2e Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 12:53:51 +0800 Subject: [PATCH 07/11] Enhance build.sh Ruby environment verification Add comprehensive Ruby environment checks: - Verify Ruby version is >= 2.7 (minimum required) - Check gem command is available - Auto-install Bundler if missing - Verify bundle install succeeds - Check FFI gem can be loaded with version info - Verify SDK can be loaded without errors - Check if native library is accessible - Summary status of Ruby environment readiness The build now provides clear feedback on what's missing and how to fix Ruby environment issues. --- build.sh | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 10 deletions(-) diff --git a/build.sh b/build.sh index 436ae27f..f2480d07 100755 --- a/build.sh +++ b/build.sh @@ -192,45 +192,121 @@ fi echo "" -# Step 5: Check Ruby and Bundler (optional) +# Step 5: Check Ruby environment echo -e "${YELLOW}Step 4: Checking Ruby environment...${NC}" cd "${SCRIPT_DIR}" RUBY_AVAILABLE=false +RUBY_READY=true # Check for Ruby if ! command -v ruby &> /dev/null; then echo -e "${YELLOW}⚠ Ruby not found. Install Ruby to use the SDK:${NC}" echo -e "${YELLOW} macOS: brew install ruby${NC}" echo -e "${YELLOW} Linux: sudo apt-get install ruby ruby-dev${NC}" + RUBY_READY=false else RUBY_AVAILABLE=true # Check Ruby version RUBY_VERSION=$(ruby -v | head -n 1 | cut -d ' ' -f 2) + RUBY_MAJOR=$(echo "$RUBY_VERSION" | cut -d '.' -f 1) + RUBY_MINOR=$(echo "$RUBY_VERSION" | cut -d '.' -f 2) + echo -e "${GREEN}✓ Ruby version: ${RUBY_VERSION}${NC}" + + # Check minimum version (>= 2.7) + if [ "$RUBY_MAJOR" -lt 2 ] || ([ "$RUBY_MAJOR" -eq 2 ] && [ "$RUBY_MINOR" -lt 7 ]); then + echo -e "${RED}✗ Ruby version must be >= 2.7 (found ${RUBY_VERSION})${NC}" + echo -e "${YELLOW} Please upgrade Ruby:${NC}" + echo -e "${YELLOW} macOS: brew upgrade ruby${NC}" + echo -e "${YELLOW} Linux: Use rbenv or rvm to install Ruby >= 2.7${NC}" + RUBY_READY=false + fi +fi + +# Check for gem command +if [ "$RUBY_AVAILABLE" = true ]; then + if ! command -v gem &> /dev/null; then + echo -e "${YELLOW}⚠ gem command not found${NC}" + RUBY_READY=false + else + echo -e "${GREEN}✓ gem command available${NC}" + fi fi # Check for Bundler if ! command -v bundle &> /dev/null; then - echo -e "${YELLOW}⚠ Bundler not found. Install with:${NC}" - echo -e "${YELLOW} gem install bundler${NC}" + echo -e "${YELLOW}⚠ Bundler not found. Installing...${NC}" + if [ "$RUBY_AVAILABLE" = true ]; then + gem install bundler --quiet 2>/dev/null && echo -e "${GREEN}✓ Bundler installed${NC}" || { + echo -e "${RED}✗ Failed to install Bundler. Install manually:${NC}" + echo -e "${YELLOW} gem install bundler${NC}" + RUBY_READY=false + } + fi else - echo -e "${GREEN}✓ Bundler found${NC}" + BUNDLER_VERSION=$(bundle -v | cut -d ' ' -f 3) + echo -e "${GREEN}✓ Bundler version: ${BUNDLER_VERSION}${NC}" +fi - # Install dependencies if Gemfile exists - if [ -f "Gemfile" ]; then - echo -e "${YELLOW} Installing dependencies...${NC}" - bundle install --quiet 2>/dev/null || true +# Install dependencies if Gemfile exists +if [ "$RUBY_READY" = true ] && [ -f "Gemfile" ]; then + echo -e "${YELLOW} Installing gem dependencies...${NC}" + if bundle install --quiet 2>/dev/null; then echo -e "${GREEN}✓ Dependencies installed${NC}" + else + echo -e "${YELLOW}⚠ bundle install failed, trying without --quiet...${NC}" + bundle install 2>&1 | tail -5 + RUBY_READY=false + fi +fi + +# Verify FFI gem is available +if [ "$RUBY_READY" = true ]; then + echo -e "${YELLOW} Verifying FFI gem...${NC}" + if ruby -e "require 'ffi'" 2>/dev/null; then + FFI_VERSION=$(ruby -e "require 'ffi'; puts FFI::VERSION" 2>/dev/null) + echo -e "${GREEN}✓ FFI gem version: ${FFI_VERSION}${NC}" + else + echo -e "${RED}✗ FFI gem not available${NC}" + echo -e "${YELLOW} Install with: gem install ffi${NC}" + RUBY_READY=false + fi +fi + +# Verify SDK can be loaded +if [ "$RUBY_READY" = true ]; then + echo -e "${YELLOW} Verifying SDK can be loaded...${NC}" + if ruby -I"${SCRIPT_DIR}/lib" -e "require 'gopher_orch'; puts 'SDK loaded successfully'" 2>/dev/null; then + echo -e "${GREEN}✓ SDK loads successfully${NC}" + + # Check if native library is available + if ruby -I"${SCRIPT_DIR}/lib" -e "require 'gopher_orch'; exit(GopherOrch.available? ? 0 : 1)" 2>/dev/null; then + echo -e "${GREEN}✓ Native library is available${NC}" + else + echo -e "${YELLOW}⚠ Native library not yet loadable (may need DYLD_LIBRARY_PATH)${NC}" + fi + else + echo -e "${RED}✗ Failed to load SDK${NC}" + echo -e "${YELLOW} Check for syntax errors in lib/ files${NC}" + RUBY_READY=false fi fi +# Summary +echo "" +if [ "$RUBY_READY" = true ]; then + echo -e "${GREEN}✓ Ruby environment is ready${NC}" +else + echo -e "${YELLOW}⚠ Ruby environment has issues (see above)${NC}" +fi + echo "" # Step 6: Run tests if RSpec is available echo -e "${YELLOW}Step 5: Running tests...${NC}" -if [ "$RUBY_AVAILABLE" = false ]; then - echo -e "${YELLOW}⚠ Skipping tests (Ruby not available)${NC}" +if [ "$RUBY_READY" = false ]; then + echo -e "${YELLOW}⚠ Skipping tests (Ruby environment not ready)${NC}" elif [ -f "Gemfile" ] && bundle exec rspec --version &> /dev/null; then bundle exec rspec --format documentation 2>/dev/null && echo -e "${GREEN}✓ Tests passed${NC}" || echo -e "${YELLOW}⚠ Some tests may have failed (native library required)${NC}" elif command -v rspec &> /dev/null; then From 2b43ea59644214ca8fc7b0b6db916d1f43e15e27 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 20:22:10 +0800 Subject: [PATCH 08/11] Fix build.sh to skip submodule clone if already present Changes: - Check if gopher-orch submodule directory exists with CMakeLists.txt before attempting git submodule update (avoids clone failures) - Check if nested gopher-mcp submodule already exists before updating - Improves build reliability when submodules are manually copied --- build.sh | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/build.sh b/build.sh index f2480d07..33be581c 100755 --- a/build.sh +++ b/build.sh @@ -33,7 +33,7 @@ echo -e "${GREEN}======================================${NC}" echo "" # Step 1: Update submodules recursively -echo -e "${YELLOW}Step 1: Updating submodules...${NC}" +echo -e "${YELLOW}Step 1: Checking submodules...${NC}" # Support custom SSH host for multiple GitHub accounts # Usage: GITHUB_SSH_HOST=bettercallsaulj ./build.sh @@ -46,12 +46,18 @@ fi git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" git config --local submodule.third_party/gopher-orch.url "git@${SSH_HOST}:GopherSecurity/gopher-orch.git" -# Update main submodule -if ! git submodule update --init 2>/dev/null; then - echo -e "${RED}Error: Failed to clone gopher-orch submodule${NC}" - echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" - echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" - exit 1 +# Check if submodule already exists and has content (e.g., manually copied) +if [ -d "${NATIVE_DIR}" ] && [ -f "${NATIVE_DIR}/CMakeLists.txt" ]; then + echo -e "${GREEN}✓ gopher-orch submodule already present${NC}" +else + # Update main submodule + echo -e "${YELLOW} Cloning gopher-orch submodule...${NC}" + if ! git submodule update --init 2>/dev/null; then + echo -e "${RED}Error: Failed to clone gopher-orch submodule${NC}" + echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" + echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" + exit 1 + fi fi # Update nested submodule (gopher-mcp inside gopher-orch) @@ -59,8 +65,16 @@ fi if [ -d "${NATIVE_DIR}" ]; then cd "${NATIVE_DIR}" git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" - # Override 'update = none' by using --checkout - git submodule update --init --checkout third_party/gopher-mcp 2>/dev/null || true + + # Check if nested submodule already exists + if [ -d "third_party/gopher-mcp" ] && [ -f "third_party/gopher-mcp/CMakeLists.txt" ]; then + echo -e "${GREEN}✓ gopher-mcp nested submodule already present${NC}" + else + # Override 'update = none' by using --checkout + echo -e "${YELLOW} Updating nested gopher-mcp submodule...${NC}" + git submodule update --init --checkout third_party/gopher-mcp 2>/dev/null || true + fi + # Also update gopher-mcp's nested submodules recursively if [ -d "third_party/gopher-mcp" ]; then cd third_party/gopher-mcp @@ -70,7 +84,7 @@ if [ -d "${NATIVE_DIR}" ]; then cd "${SCRIPT_DIR}" fi -echo -e "${GREEN}✓ Submodules updated${NC}" +echo -e "${GREEN}✓ Submodules ready${NC}" echo "" # Step 2: Check if gopher-orch exists From 8d27ea89e7a13b1ac14ff6cd423fb4c7b56011ee Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 20:44:24 +0800 Subject: [PATCH 09/11] Fix build.sh to prefer Homebrew Ruby on macOS Homebrew Ruby is "keg-only" and not symlinked into /usr/local by default, so the build script was falling back to the older system Ruby. This change adds detection for Homebrew Ruby paths (both Intel and Apple Silicon) and adds them to PATH before checking the Ruby version. Changes: - Add detection for /usr/local/opt/ruby/bin (Intel Mac) - Add detection for /opt/homebrew/opt/ruby/bin (Apple Silicon) - Include gem bin directory in PATH for bundler access - Print confirmation when Homebrew Ruby is detected --- build.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/build.sh b/build.sh index 33be581c..f9059229 100755 --- a/build.sh +++ b/build.sh @@ -213,6 +213,21 @@ cd "${SCRIPT_DIR}" RUBY_AVAILABLE=false RUBY_READY=true +# Prefer Homebrew Ruby on macOS (it's keg-only so not in PATH by default) +if [[ "$OSTYPE" == "darwin"* ]]; then + HOMEBREW_RUBY="/usr/local/opt/ruby/bin" + HOMEBREW_GEMS="/usr/local/lib/ruby/gems/4.0.0/bin" + # Also check Apple Silicon path + if [ ! -d "$HOMEBREW_RUBY" ]; then + HOMEBREW_RUBY="/opt/homebrew/opt/ruby/bin" + HOMEBREW_GEMS="/opt/homebrew/lib/ruby/gems/4.0.0/bin" + fi + if [ -d "$HOMEBREW_RUBY" ]; then + export PATH="$HOMEBREW_RUBY:$HOMEBREW_GEMS:$PATH" + echo -e "${GREEN}✓ Using Homebrew Ruby${NC}" + fi +fi + # Check for Ruby if ! command -v ruby &> /dev/null; then echo -e "${YELLOW}⚠ Ruby not found. Install Ruby to use the SDK:${NC}" From 39417fb787489dd2033d4569f98fd1b499762d5c Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 23 Jan 2026 20:49:12 +0800 Subject: [PATCH 10/11] Fix example script to prefer Homebrew Ruby on macOS Same fix as build.sh - add detection for Homebrew Ruby paths so the example script uses Ruby 4.0 instead of system Ruby 2.6. --- examples/client_example_json_run.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/examples/client_example_json_run.sh b/examples/client_example_json_run.sh index 767d0d29..58b53ca6 100755 --- a/examples/client_example_json_run.sh +++ b/examples/client_example_json_run.sh @@ -10,6 +10,20 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color +# Prefer Homebrew Ruby on macOS (it's keg-only so not in PATH by default) +if [[ "$OSTYPE" == "darwin"* ]]; then + HOMEBREW_RUBY="/usr/local/opt/ruby/bin" + HOMEBREW_GEMS="/usr/local/lib/ruby/gems/4.0.0/bin" + # Also check Apple Silicon path + if [ ! -d "$HOMEBREW_RUBY" ]; then + HOMEBREW_RUBY="/opt/homebrew/opt/ruby/bin" + HOMEBREW_GEMS="/opt/homebrew/lib/ruby/gems/4.0.0/bin" + fi + if [ -d "$HOMEBREW_RUBY" ]; then + export PATH="$HOMEBREW_RUBY:$HOMEBREW_GEMS:$PATH" + fi +fi + # Get the script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" From 8e0908863861503c78c8dacfad0ea1becdac274a Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sat, 24 Jan 2026 00:26:05 +0800 Subject: [PATCH 11/11] Add RuboCop for Ruby code formatting and linting Adds RuboCop configuration for consistent code style across the project. Changes: - Add .rubocop.yml with project-specific rules - Add rubocop-rspec gem to Gemfile for RSpec-specific cops - Update Rakefile with rubocop and rubocop:fix tasks - Update README.md with RuboCop commands --- .rubocop.yml | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Gemfile | 1 + README.md | 4 +++ Rakefile | 12 ++++++++- 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .rubocop.yml diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..22138b0d --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,71 @@ +# RuboCop configuration for gopher-orch Ruby SDK + +require: + - rubocop-rspec + +AllCops: + TargetRubyVersion: 2.7 + NewCops: enable + SuggestExtensions: false + Exclude: + - 'vendor/**/*' + - 'examples/server*/**/*' + - 'third_party/**/*' + - 'native/**/*' + +# Layout +Layout/LineLength: + Max: 120 + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented + +# Metrics +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + - '*.gemspec' + +Metrics/MethodLength: + Max: 20 + +Metrics/ClassLength: + Max: 150 + +Metrics/AbcSize: + Max: 25 + +# Style +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: true + +Style/StringLiterals: + EnforcedStyle: single_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: single_quotes + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: consistent_comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: consistent_comma + +# Naming +Naming/FileName: + Exclude: + - 'Gemfile' + - 'Rakefile' + +# RSpec +RSpec/ExampleLength: + Max: 15 + +RSpec/MultipleExpectations: + Max: 5 + +RSpec/NestedGroups: + Max: 4 diff --git a/Gemfile b/Gemfile index f6c2db1c..6926c03d 100644 --- a/Gemfile +++ b/Gemfile @@ -8,4 +8,5 @@ group :development, :test do gem 'rake', '~> 13.0' gem 'rspec', '~> 3.12' gem 'rubocop', '~> 1.50', require: false + gem 'rubocop-rspec', '~> 2.20', require: false end diff --git a/README.md b/README.md index 1eb635d8..c7f87d4d 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,10 @@ gopher-mcp-ruby/ | `GITHUB_SSH_HOST=alias ./build.sh` | Build with custom SSH host | | `bundle install` | Install Ruby dependencies | | `bundle exec rspec` | Run tests | +| `bundle exec rubocop` | Check code style | +| `bundle exec rubocop -A` | Auto-fix code style issues | +| `rake rubocop` | Check code style (via Rake) | +| `rake rubocop:fix` | Auto-fix code style (via Rake) | ### Rebuilding Native Library diff --git a/Rakefile b/Rakefile index 82bb534a..c8156575 100644 --- a/Rakefile +++ b/Rakefile @@ -2,7 +2,17 @@ require 'bundler/gem_tasks' require 'rspec/core/rake_task' +require 'rubocop/rake_task' RSpec::Core::RakeTask.new(:spec) -task default: :spec +RuboCop::RakeTask.new(:rubocop) do |task| + task.options = ['--display-cop-names'] +end + +desc 'Run RuboCop with auto-correct' +task 'rubocop:fix' do + sh 'bundle exec rubocop -A' +end + +task default: %i[spec rubocop]