From a33296740e44689554dc7e510f04870f3623f386 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:56:30 -0800 Subject: [PATCH 001/197] Add core types for orchestration framework (#5) Introduces foundational types for gopher-orch: - Type aliases for MCP types (JsonValue, Dispatcher, Result, optional) - OrchError namespace with error codes for orchestration operations - Helper functions: makeSuccess, makeOrchError for Result creation --- include/gopher/orch/core/types.h | 121 +++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 include/gopher/orch/core/types.h diff --git a/include/gopher/orch/core/types.h b/include/gopher/orch/core/types.h new file mode 100644 index 00000000..e7a24c2c --- /dev/null +++ b/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 for C++14 compatibility +#include "mcp/core/optional.h" +#include "mcp/core/result.h" +#include "mcp/core/type_helpers.h" +#include "mcp/core/variant.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 From d6e5cad9975a79b5ce9b0f9b05695b94b9c12119 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:56:55 -0800 Subject: [PATCH 002/197] Add RunnableConfig for orchestration framework (#5) Configuration options passed to Runnable invocations: - Builder pattern with withTag, withMetadata, withTimeout - Support for max concurrency and recursion limits - Config merging and child config creation for composition chains --- include/gopher/orch/core/config.h | 119 ++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 include/gopher/orch/core/config.h diff --git a/include/gopher/orch/core/config.h b/include/gopher/orch/core/config.h new file mode 100644 index 00000000..7a95d2cb --- /dev/null +++ b/include/gopher/orch/core/config.h @@ -0,0 +1,119 @@ +#pragma once + +// RunnableConfig - Configuration options for Runnable invocations +// Provides metadata, tags, and execution options that flow through the chain + +#include +#include +#include +#include + +#include "gopher/orch/core/types.h" + +namespace gopher { +namespace orch { +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; + } + + // 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 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_; } + + // 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_; + } + 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 +}; + +} // namespace core +} // namespace orch +} // namespace gopher From 4e9fb65a41acd4c3ec3548c4132fabfab56f2639 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:57:51 -0800 Subject: [PATCH 003/197] Add Runnable interface for orchestration framework (#5) Universal composable interface for async operations: - Runnable base class with async invoke - Dispatcher-native callbacks invoked in event loop context - JsonRunnable type alias for type-erased JSON operations --- include/gopher/orch/core/runnable.h | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 include/gopher/orch/core/runnable.h diff --git a/include/gopher/orch/core/runnable.h b/include/gopher/orch/core/runnable.h new file mode 100644 index 00000000..b1ad39f5 --- /dev/null +++ b/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 From 57177cb7094b28912685f402809e00720af7f696 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:58:07 -0800 Subject: [PATCH 004/197] Add Lambda runnable for orchestration framework (#5) Create Runnable from functions or lambdas: - fromSync for synchronous functions posted to dispatcher - fromAsync for functions managing their own async execution - makeJsonLambda convenience function for JSON operations --- include/gopher/orch/core/lambda.h | 143 ++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 include/gopher/orch/core/lambda.h diff --git a/include/gopher/orch/core/lambda.h b/include/gopher/orch/core/lambda.h new file mode 100644 index 00000000..3f21b9de --- /dev/null +++ b/include/gopher/orch/core/lambda.h @@ -0,0 +1,143 @@ +#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 From fd58d6d324d0ee559083266ea7a3cc5f42712160 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:58:46 -0800 Subject: [PATCH 005/197] Add Sequence composition for orchestration framework (#5) Chain runnables together with output flowing to next input: - Sequence2 for type-safe two-step chains - Sequence for dynamic JSON runnable chains - Short-circuits on first error, builder pattern with sequence() --- include/gopher/orch/composition/sequence.h | 206 +++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 include/gopher/orch/composition/sequence.h diff --git a/include/gopher/orch/composition/sequence.h b/include/gopher/orch/composition/sequence.h new file mode 100644 index 00000000..766f1376 --- /dev/null +++ b/include/gopher/orch/composition/sequence.h @@ -0,0 +1,206 @@ +#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 From cce6dec6e3ff9b3a8045893a27b8d5544a380762 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:58:57 -0800 Subject: [PATCH 006/197] Add Parallel composition for orchestration framework (#5) Execute multiple runnables concurrently: - Same input distributed to all branches - Results collected into JSON object by key - Fail-fast error handling, builder pattern with parallel() --- include/gopher/orch/composition/parallel.h | 181 +++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 include/gopher/orch/composition/parallel.h diff --git a/include/gopher/orch/composition/parallel.h b/include/gopher/orch/composition/parallel.h new file mode 100644 index 00000000..e69cf6ad --- /dev/null +++ b/include/gopher/orch/composition/parallel.h @@ -0,0 +1,181 @@ +#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 From 62b887c67d922105905f0ff03dac15c26bb873fb Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:59:25 -0800 Subject: [PATCH 007/197] Add Server interface for orchestration framework (#5) Protocol-agnostic server abstraction: - Server base class with connect, listTools, callTool methods - ServerTool wraps server tools as Runnable - ToolInfo for tool metadata and input schema --- include/gopher/orch/server/server.h | 139 ++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 include/gopher/orch/server/server.h diff --git a/include/gopher/orch/server/server.h b/include/gopher/orch/server/server.h new file mode 100644 index 00000000..b94bdcf1 --- /dev/null +++ b/include/gopher/orch/server/server.h @@ -0,0 +1,139 @@ +#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) +// - ToolInfo: Metadata about a tool + +#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 ToolInfo { + std::string name; + std::string description; + JsonValue inputSchema; // JSON Schema for tool arguments + + ToolInfo() = default; + ToolInfo(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 ToolListCallback = 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, ToolListCallback 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 ToolInfo& info) + : server_(std::move(server)), info_(info) {} + + std::string name() const override { return info_.name; } + + const ToolInfo& 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_; + ToolInfo info_; +}; + +} // namespace server +} // namespace orch +} // namespace gopher From fa8a14a43d815eda4e530092888ee657cc9e0240 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 19:59:44 -0800 Subject: [PATCH 008/197] Add MockServer for orchestration framework (#5) In-memory server implementation for testing: - setResponse, setError, setHandler for tool configuration - Call tracking with callCount, lastArguments - Delay simulation for testing async behavior --- include/gopher/orch/server/mock_server.h | 271 +++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 include/gopher/orch/server/mock_server.h diff --git a/include/gopher/orch/server/mock_server.h b/include/gopher/orch/server/mock_server.h new file mode 100644 index 00000000..afaa91cb --- /dev/null +++ b/include/gopher/orch/server/mock_server.h @@ -0,0 +1,271 @@ +#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, ToolListCallback 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] = ToolInfo(name, description); + return *this; + } + + // Add a tool with schema + MockServer& addTool(const ToolInfo& 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 From 3ddcb7972b522d6c1f041285cd99555363d44d08 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:00:42 -0800 Subject: [PATCH 009/197] Add main orch.h header for orchestration framework (#5) Convenience header that includes all framework components: - Core types, config, runnable, lambda - Composition patterns: sequence, parallel - Server abstraction: server, mock_server - Re-exports types at gopher::orch namespace level --- include/gopher/orch/orch.h | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 include/gopher/orch/orch.h diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h new file mode 100644 index 00000000..d3a26339 --- /dev/null +++ b/include/gopher/orch/orch.h @@ -0,0 +1,78 @@ +#pragma once + +// gopher-orch - MCP Server Orchestration Framework +// +// Provides composable building blocks for agentic workflows: +// - Runnable: Universal async operation interface +// - Sequence, Parallel: Composition patterns +// - 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/types.h" +#include "gopher/orch/core/config.h" +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/core/lambda.h" + +// Composition patterns +#include "gopher/orch/composition/sequence.h" +#include "gopher/orch/composition/parallel.h" + +// Server abstraction +#include "gopher/orch/server/server.h" +#include "gopher/orch/server/mock_server.h" + +// 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::ParallelBuilder; +using composition::parallel; +using composition::Sequence; +using composition::Sequence2; +using composition::SequenceBuilder; +using composition::sequence; + +// Re-export server components +using server::ConnectionCallback; +using server::ConnectionState; +using server::makeMockServer; +using server::MockServer; +using server::Server; +using server::ServerPtr; +using server::ServerTool; +using server::ServerToolPtr; +using server::ToolInfo; +using server::ToolListCallback; + +} // namespace orch +} // namespace gopher From eba46994667952103ab2029d9a1712a00b4b9046 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:02:21 -0800 Subject: [PATCH 010/197] Add unit tests for orchestration framework (#5) Comprehensive tests for all core components: - Lambda: sync, config, error handling - Sequence: basic, short-circuit, empty - Parallel: basic, fail-fast, empty - MockServer: basic, custom handler, errors - Integration: sequence and parallel with server tools --- tests/CMakeLists.txt | 9 + tests/gopher/orch/orch_test.cc | 507 +++++++++++++++++++++++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 tests/gopher/orch/orch_test.cc diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 88a9c7d4..c5cb5f6d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,11 @@ set(ORCH_CORE_TEST_SOURCES orch/hello_test.cpp ) +# New gopher/orch framework tests +set(ORCH_FRAMEWORK_TEST_SOURCES + gopher/orch/orch_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}) @@ -43,9 +48,13 @@ 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 a combined orch test executable for convenience add_executable(gopher-orch-tests ${ORCH_CORE_TEST_SOURCES} + ${ORCH_FRAMEWORK_TEST_SOURCES} ${TEST_UTIL_SOURCES} ) diff --git a/tests/gopher/orch/orch_test.cc b/tests/gopher/orch/orch_test.cc new file mode 100644 index 00000000..ce6207ad --- /dev/null +++ b/tests/gopher/orch/orch_test.cc @@ -0,0 +1,507 @@ +// Unit tests for gopher-orch framework +// Tests core components: Runnable, Lambda, Sequence, Parallel, MockServer + +#include "gopher/orch/orch.h" + +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "mcp/event/libevent_dispatcher.h" + +using namespace gopher::orch; +using namespace gopher::orch::core; +using namespace gopher::orch::composition; +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_; +}; + +// ============================================================================= +// 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"); +} + +// ============================================================================= +// 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()); +} + +// ============================================================================= +// 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()); +} + +// ============================================================================= +// 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, ToolListCallback 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"); +} + +// ============================================================================= +// 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"); +} + +// Main +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 50a902a756d0743eebcc5bf5734daeaadbc2045f Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:05:26 -0800 Subject: [PATCH 011/197] make format code to apply clang-format (#5) --- include/gopher/orch/composition/parallel.h | 22 ++-- include/gopher/orch/composition/sequence.h | 29 +++-- include/gopher/orch/core/config.h | 4 +- include/gopher/orch/core/lambda.h | 32 ++--- include/gopher/orch/core/runnable.h | 8 +- include/gopher/orch/core/types.h | 3 +- include/gopher/orch/orch.h | 12 +- include/gopher/orch/server/mock_server.h | 22 ++-- include/gopher/orch/server/server.h | 3 +- tests/gopher/orch/orch_test.cc | 144 +++++++++++---------- 10 files changed, 147 insertions(+), 132 deletions(-) diff --git a/include/gopher/orch/composition/parallel.h b/include/gopher/orch/composition/parallel.h index e69cf6ad..fe3fb1e2 100644 --- a/include/gopher/orch/composition/parallel.h +++ b/include/gopher/orch/composition/parallel.h @@ -47,7 +47,8 @@ class Parallel : public JsonRunnable { } std::string result = "Parallel("; for (size_t i = 0; i < branches_.size(); ++i) { - if (i > 0) result += ", "; + if (i > 0) + result += ", "; result += branches_[i].first; } result += ")"; @@ -67,8 +68,8 @@ class Parallel : public JsonRunnable { } // Shared state for collecting results from all branches - auto state = std::make_shared(branches_.size(), - std::move(callback)); + auto state = + std::make_shared(branches_.size(), std::move(callback)); // Launch all branches concurrently for (size_t i = 0; i < branches_.size(); ++i) { @@ -76,9 +77,10 @@ class Parallel : public JsonRunnable { const auto& runnable = branches_[i].second; runnable->invoke(input, config.child(), dispatcher, - [state, key, &dispatcher](Result result) { - state->onBranchComplete(key, std::move(result), dispatcher); - }); + [state, key, &dispatcher](Result result) { + state->onBranchComplete(key, std::move(result), + dispatcher); + }); } } @@ -113,9 +115,8 @@ class Parallel : public JsonRunnable { // 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)); - }); + dispatcher.post( + [cb = std::move(cb), error]() { cb(Result(error)); }); return; } @@ -158,7 +159,8 @@ class ParallelBuilder { // 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))); + parallel_->add(key, + std::static_pointer_cast(std::move(runnable))); return *this; } diff --git a/include/gopher/orch/composition/sequence.h b/include/gopher/orch/composition/sequence.h index 766f1376..3327b7a8 100644 --- a/include/gopher/orch/composition/sequence.h +++ b/include/gopher/orch/composition/sequence.h @@ -44,17 +44,17 @@ class Sequence2 : public Runnable { // 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)); - } - }); + [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: @@ -133,9 +133,10 @@ class Sequence : public JsonRunnable { 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 { + 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)); diff --git a/include/gopher/orch/core/config.h b/include/gopher/orch/core/config.h index 7a95d2cb..cabd1460 100644 --- a/include/gopher/orch/core/config.h +++ b/include/gopher/orch/core/config.h @@ -109,9 +109,9 @@ class RunnableConfig { std::map tags_; std::map metadata_; std::string run_name_; - size_t max_concurrency_ = 0; // 0 means unlimited + 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 + size_t recursion_limit_ = 25; // Default recursion limit }; } // namespace core diff --git a/include/gopher/orch/core/lambda.h b/include/gopher/orch/core/lambda.h index 3f21b9de..5cda34c2 100644 --- a/include/gopher/orch/core/lambda.h +++ b/include/gopher/orch/core/lambda.h @@ -17,15 +17,14 @@ 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&)>; +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)>; +using AsyncFunc = std::function)>; // Lambda Runnable - wraps a function as a Runnable // @@ -62,10 +61,11 @@ class Lambda : public Runnable { // 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)); - }); + 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)); @@ -75,10 +75,14 @@ class Lambda : public Runnable { 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) {} + : 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) {} + : async_func_(std::move(func)), + name_(std::move(name)), + is_sync_(is_sync) {} SyncFunc sync_func_; AsyncFunc async_func_; @@ -91,8 +95,7 @@ class Lambda : public Runnable { // Create Lambda from sync function: (Input, Config) -> Result template std::shared_ptr> makeLambda( - SyncFunc func, - const std::string& name = "Lambda") { + SyncFunc func, const std::string& name = "Lambda") { return Lambda::fromSync(std::move(func), name); } @@ -112,8 +115,7 @@ std::shared_ptr> makeLambda( // Create Lambda from async function template std::shared_ptr> makeLambdaAsync( - AsyncFunc func, - const std::string& name = "Lambda") { + AsyncFunc func, const std::string& name = "Lambda") { return Lambda::fromAsync(std::move(func), name); } diff --git a/include/gopher/orch/core/runnable.h b/include/gopher/orch/core/runnable.h index b1ad39f5..8040caa4 100644 --- a/include/gopher/orch/core/runnable.h +++ b/include/gopher/orch/core/runnable.h @@ -78,10 +78,10 @@ class Runnable : public std::enable_shared_from_this> { static void postResult(Dispatcher& dispatcher, ResultCallback callback, Result result) { - dispatcher.post([callback = std::move(callback), - result = std::move(result)]() mutable { - callback(std::move(result)); - }); + dispatcher.post( + [callback = std::move(callback), result = std::move(result)]() mutable { + callback(std::move(result)); + }); } // Helper to post error to dispatcher diff --git a/include/gopher/orch/core/types.h b/include/gopher/orch/core/types.h index e7a24c2c..693f30b7 100644 --- a/include/gopher/orch/core/types.h +++ b/include/gopher/orch/core/types.h @@ -60,7 +60,8 @@ 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) +// Using enum for C++14 compatibility (constexpr static members need out-of-line +// definition) namespace OrchError { enum : int { OK = 0, diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index d3a26339..02d228e5 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -15,18 +15,18 @@ // - Explicit - no hidden magic // Core types and utilities -#include "gopher/orch/core/types.h" #include "gopher/orch/core/config.h" -#include "gopher/orch/core/runnable.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/sequence.h" #include "gopher/orch/composition/parallel.h" +#include "gopher/orch/composition/sequence.h" // Server abstraction -#include "gopher/orch/server/server.h" #include "gopher/orch/server/mock_server.h" +#include "gopher/orch/server/server.h" // Convenience namespace imports namespace gopher { @@ -55,12 +55,12 @@ using core::RunnableConfig; // Re-export composition patterns using composition::Parallel; -using composition::ParallelBuilder; using composition::parallel; +using composition::ParallelBuilder; using composition::Sequence; +using composition::sequence; using composition::Sequence2; using composition::SequenceBuilder; -using composition::sequence; // Re-export server components using server::ConnectionCallback; diff --git a/include/gopher/orch/server/mock_server.h b/include/gopher/orch/server/mock_server.h index afaa91cb..94b137b6 100644 --- a/include/gopher/orch/server/mock_server.h +++ b/include/gopher/orch/server/mock_server.h @@ -135,10 +135,11 @@ class MockServer : public Server { 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)); - }); + 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), @@ -168,7 +169,8 @@ class MockServer : public Server { } // Set the response for a tool - MockServer& setResponse(const std::string& toolName, const JsonValue& response) { + MockServer& setResponse(const std::string& toolName, + const JsonValue& response) { std::lock_guard lock(mutex_); configs_[toolName].response = response; configs_[toolName].error = nullopt; @@ -197,8 +199,9 @@ class MockServer : public Server { } // Set a custom handler for a tool - MockServer& setHandler(const std::string& toolName, - std::function(const JsonValue&)> handler) { + MockServer& setHandler( + const std::string& toolName, + std::function(const JsonValue&)> handler) { std::lock_guard lock(mutex_); configs_[toolName].handler = std::move(handler); return *this; @@ -260,9 +263,8 @@ class MockServer : public Server { }; // Factory function -inline std::shared_ptr makeMockServer( - const std::string& name, - const std::string& id = "") { +inline std::shared_ptr makeMockServer(const std::string& name, + const std::string& id = "") { return std::make_shared(name, id); } diff --git a/include/gopher/orch/server/server.h b/include/gopher/orch/server/server.h index b94bdcf1..0cbb1f87 100644 --- a/include/gopher/orch/server/server.h +++ b/include/gopher/orch/server/server.h @@ -126,7 +126,8 @@ class ServerTool : public JsonRunnable { const RunnableConfig& config, Dispatcher& dispatcher, Callback callback) override { - server_->callTool(info_.name, input, config, dispatcher, std::move(callback)); + server_->callTool(info_.name, input, config, dispatcher, + std::move(callback)); } private: diff --git a/tests/gopher/orch/orch_test.cc b/tests/gopher/orch/orch_test.cc index ce6207ad..b06ee6ca 100644 --- a/tests/gopher/orch/orch_test.cc +++ b/tests/gopher/orch/orch_test.cc @@ -9,9 +9,10 @@ #include #include -#include "gtest/gtest.h" #include "mcp/event/libevent_dispatcher.h" +#include "gtest/gtest.h" + using namespace gopher::orch; using namespace gopher::orch::core; using namespace gopher::orch::composition; @@ -28,25 +29,26 @@ class OrchTest : public ::testing::Test { // Run dispatcher until callback completes template - T runToCompletion(std::function)> operation) { + 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(); - }); + 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; + if (done) + break; } dispatcher_->run(mcp::event::RunType::NonBlock); std::this_thread::sleep_for(std::chrono::milliseconds(1)); @@ -66,19 +68,19 @@ class OrchTest : public ::testing::Test { 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(); - }); + 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; + if (done) + break; } dispatcher_->run(mcp::event::RunType::NonBlock); std::this_thread::sleep_for(std::chrono::milliseconds(1)); @@ -107,8 +109,8 @@ TEST_F(OrchTest, LambdaSyncBasic) { EXPECT_EQ(doubler->name(), "Doubler"); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { JsonValue input = JsonValue::object(); input["value"] = JsonValue(21); doubler->invoke(input, RunnableConfig(), d, std::move(cb)); @@ -120,10 +122,12 @@ TEST_F(OrchTest, LambdaSyncBasic) { TEST_F(OrchTest, LambdaWithConfig) { // Lambda that uses config auto configReader = makeJsonLambda( - [](const JsonValue& input, const RunnableConfig& config) -> Result { + [](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")); + result["mode"] = + JsonValue(tag.has_value() ? tag.value() : std::string("default")); return makeSuccess(JsonValue(result)); }, "ConfigReader"); @@ -131,8 +135,8 @@ TEST_F(OrchTest, LambdaWithConfig) { RunnableConfig config; config.withTag("mode", "test"); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { configReader->invoke(JsonValue::object(), config, d, std::move(cb)); }); @@ -142,13 +146,15 @@ TEST_F(OrchTest, LambdaWithConfig) { TEST_F(OrchTest, LambdaError) { auto errorLambda = makeJsonLambda( [](const JsonValue&) -> Result { - return Result(Error(OrchError::INVALID_ARGUMENT, "Test error")); + 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)); + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + errorLambda->invoke(JsonValue::object(), RunnableConfig(), d, + std::move(cb)); }); EXPECT_TRUE(mcp::holds_alternative(result)); @@ -184,8 +190,8 @@ TEST_F(OrchTest, SequenceBasic) { EXPECT_EQ(seq->size(), 2u); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { JsonValue input = JsonValue::object(); input["value"] = JsonValue(10); seq->invoke(input, RunnableConfig(), d, std::move(cb)); @@ -201,7 +207,8 @@ TEST_F(OrchTest, SequenceShortCircuit) { auto step1 = makeJsonLambda( [](const JsonValue&) -> Result { - return Result(Error(OrchError::INVALID_ARGUMENT, "Step1 failed")); + return Result( + Error(OrchError::INVALID_ARGUMENT, "Step1 failed")); }, "FailingStep"); @@ -214,8 +221,8 @@ TEST_F(OrchTest, SequenceShortCircuit) { auto seq = sequence().add(step1).add(step2).build(); - auto result = runToCompletionResult( - [&](Dispatcher& d, JsonCallback cb) { + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { seq->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); }); @@ -230,8 +237,8 @@ TEST_F(OrchTest, SequenceEmpty) { JsonValue input = JsonValue::object(); input["pass_through"] = JsonValue(true); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { seq->invoke(input, RunnableConfig(), d, std::move(cb)); }); @@ -260,15 +267,13 @@ TEST_F(OrchTest, ParallelBasic) { }, "BranchB"); - auto par = parallel("TestParallel") - .add("a", branchA) - .add("b", branchB) - .build(); + auto par = + parallel("TestParallel").add("a", branchA).add("b", branchB).build(); EXPECT_EQ(par->size(), 2u); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { JsonValue input = JsonValue::object(); input["value"] = JsonValue(10); par->invoke(input, RunnableConfig(), d, std::move(cb)); @@ -284,7 +289,8 @@ TEST_F(OrchTest, ParallelFailFast) { auto branchA = makeJsonLambda( [](const JsonValue&) -> Result { - return Result(Error(OrchError::INTERNAL_ERROR, "Branch A failed")); + return Result( + Error(OrchError::INTERNAL_ERROR, "Branch A failed")); }, "FailingBranch"); @@ -299,8 +305,8 @@ TEST_F(OrchTest, ParallelFailFast) { auto par = parallel().add("a", branchA).add("b", branchB).build(); - auto result = runToCompletionResult( - [&](Dispatcher& d, JsonCallback cb) { + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { par->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); }); @@ -312,8 +318,8 @@ TEST_F(OrchTest, ParallelFailFast) { TEST_F(OrchTest, ParallelEmpty) { auto par = parallel().build(); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { par->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); }); @@ -331,8 +337,7 @@ TEST_F(OrchTest, MockServerBasic) { JsonValue response = JsonValue::object(); response["message"] = JsonValue("Hello!"); - server->addTool("greet", "Greets a person") - .setResponse("greet", response); + server->addTool("greet", "Greets a person").setResponse("greet", response); EXPECT_EQ(server->name(), "test-server"); EXPECT_EQ(server->connectionState(), ConnectionState::DISCONNECTED); @@ -360,8 +365,8 @@ TEST_F(OrchTest, MockServerBasic) { EXPECT_EQ(greet->name(), "greet"); // Call tool - JsonValue toolResult = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue toolResult = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { greet->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); }); @@ -372,8 +377,8 @@ TEST_F(OrchTest, MockServerBasic) { TEST_F(OrchTest, MockServerCustomHandler) { auto server = makeMockServer("handler-server"); - server->addTool("echo") - .setHandler("echo", [](const JsonValue& args) -> Result { + server->addTool("echo").setHandler( + "echo", [](const JsonValue& args) -> Result { JsonValue result = JsonValue::object(); result["echoed"] = args; return makeSuccess(JsonValue(result)); @@ -387,8 +392,8 @@ TEST_F(OrchTest, MockServerCustomHandler) { JsonValue input = JsonValue::object(); input["data"] = JsonValue("test"); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { echo->invoke(input, RunnableConfig(), d, std::move(cb)); }); @@ -406,16 +411,16 @@ TEST_F(OrchTest, MockServerToolNotFound) { TEST_F(OrchTest, MockServerError) { auto server = makeMockServer("error-server"); - server->addTool("fail") - .setError("fail", OrchError::INTERNAL_ERROR, "Simulated failure"); + 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) { + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { fail->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); }); @@ -442,7 +447,8 @@ TEST_F(OrchTest, SequenceWithServer) { server->addTool("process", "Process data") .setHandler("process", [](const JsonValue& args) -> Result { JsonValue result = JsonValue::object(); - result["processed"] = JsonValue(args["data"].getString() + "-processed"); + result["processed"] = + JsonValue(args["data"].getString() + "-processed"); return makeSuccess(JsonValue(result)); }); @@ -458,8 +464,8 @@ TEST_F(OrchTest, SequenceWithServer) { JsonValue input = JsonValue::object(); input["id"] = JsonValue("123"); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { workflow->invoke(input, RunnableConfig(), d, std::move(cb)); }); @@ -469,15 +475,15 @@ TEST_F(OrchTest, SequenceWithServer) { TEST_F(OrchTest, ParallelWithServerTools) { auto server = makeMockServer("parallel-server"); - server->addTool("tool_a") - .setHandler("tool_a", [](const JsonValue&) -> Result { + 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 { + server->addTool("tool_b").setHandler( + "tool_b", [](const JsonValue&) -> Result { JsonValue result = JsonValue::object(); result["from"] = JsonValue("tool_b"); return makeSuccess(JsonValue(result)); @@ -491,10 +497,10 @@ TEST_F(OrchTest, ParallelWithServerTools) { .add("b", server->tool("tool_b")) .build(); - JsonValue result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { - workflow->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); - }); + 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"); From a09d61a8416c454311d5543b3b421ecf6746851e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:48:45 -0800 Subject: [PATCH 012/197] Add Router composition pattern for conditional branching (#7) Router evaluates conditions against input and routes to matching runnable: - Type-safe Router template - Fluent builder API with when() and otherwise() - Returns error if no condition matches and no default provided - JsonRouter alias for type-erased JSON routing --- include/gopher/orch/composition/router.h | 147 +++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 include/gopher/orch/composition/router.h diff --git a/include/gopher/orch/composition/router.h b/include/gopher/orch/composition/router.h new file mode 100644 index 00000000..31dc45f6 --- /dev/null +++ b/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 From 84d3633aa9e90f273fcb4d93accd1c0c31b09a85 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:49:18 -0800 Subject: [PATCH 013/197] Add Retry resilience pattern with exponential backoff (#7) Retry wraps a runnable with automatic retry logic: - Configurable max attempts, initial delay, backoff multiplier - Optional jitter to prevent thundering herd - RetryPolicy with exponential() and fixed() factory methods - Optional retry condition to filter retryable errors - Timer-based delay keeps timer alive via shared state --- include/gopher/orch/resilience/retry.h | 208 +++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 include/gopher/orch/resilience/retry.h diff --git a/include/gopher/orch/resilience/retry.h b/include/gopher/orch/resilience/retry.h new file mode 100644 index 00000000..d108fd9b --- /dev/null +++ b/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 From c8517ff49ee238d3effa7f11457b2f9bfb7ab604 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:49:29 -0800 Subject: [PATCH 014/197] Add Timeout resilience pattern (#7) Timeout limits execution time for any runnable: - Thread-safe race handling between timeout and completion - Uses atomic compare_exchange to ensure only one callback fires - TimeoutState struct holds timer and callback - Returns TIMEOUT error if operation exceeds limit --- include/gopher/orch/resilience/timeout.h | 130 +++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 include/gopher/orch/resilience/timeout.h diff --git a/include/gopher/orch/resilience/timeout.h b/include/gopher/orch/resilience/timeout.h new file mode 100644 index 00000000..abbd4715 --- /dev/null +++ b/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 From 07c371d19f0161e05a287ce745213219e14bd0c8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:49:39 -0800 Subject: [PATCH 015/197] Add Fallback resilience pattern (#7) Fallback tries alternatives on failure: - Chains primary with multiple fallback runnables - Tries each in order until one succeeds - Fluent builder API with orElse() - Returns FALLBACK_EXHAUSTED error if all fail --- include/gopher/orch/resilience/fallback.h | 156 ++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 include/gopher/orch/resilience/fallback.h diff --git a/include/gopher/orch/resilience/fallback.h b/include/gopher/orch/resilience/fallback.h new file mode 100644 index 00000000..59758bab --- /dev/null +++ b/include/gopher/orch/resilience/fallback.h @@ -0,0 +1,156 @@ +#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 From 0462ff01f96dcfd6dad091b77b3ef9cbd384c1ee Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:49:49 -0800 Subject: [PATCH 016/197] Add CircuitBreaker resilience pattern (#7) CircuitBreaker prevents cascade failures: - Three states: CLOSED, OPEN, HALF_OPEN - Opens after configurable failure threshold - Recovers to HALF_OPEN after recovery timeout - Closes after successful calls in half-open state - Manual reset() for testing and admin purposes - Thread-safe state transitions with atomic operations --- .../gopher/orch/resilience/circuit_breaker.h | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 include/gopher/orch/resilience/circuit_breaker.h diff --git a/include/gopher/orch/resilience/circuit_breaker.h b/include/gopher/orch/resilience/circuit_breaker.h new file mode 100644 index 00000000..9f743707 --- /dev/null +++ b/include/gopher/orch/resilience/circuit_breaker.h @@ -0,0 +1,251 @@ +#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 From a68d9f45cee5fa57bfe0c534970d26510445110c Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 20:50:04 -0800 Subject: [PATCH 017/197] Update orch.h header with Router and resilience patterns (#7) Add includes and namespace re-exports for new components: - Router composition pattern - Resilience patterns: Retry, Timeout, Fallback, CircuitBreaker - Factory functions: withRetry, withTimeout, withFallback, withCircuitBreaker - Policy types: RetryPolicy, CircuitBreakerPolicy, CircuitState --- include/gopher/orch/orch.h | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 02d228e5..2537bea5 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -22,8 +22,15 @@ // 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" + // Server abstraction #include "gopher/orch/server/mock_server.h" #include "gopher/orch/server/server.h" @@ -57,11 +64,32 @@ using core::RunnableConfig; 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 server components using server::ConnectionCallback; using server::ConnectionState; From de891744c79feefae039a2033f1db30ec5537498 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:04:48 -0800 Subject: [PATCH 018/197] Add shared test fixture for orch unit tests (#7) Common test infrastructure for all orch tests: - OrchTest fixture with dispatcher setup/teardown - runToCompletion() helper for sync execution - runToCompletionResult() helper for error handling --- tests/gopher/orch/orch_test_fixture.h | 96 +++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/gopher/orch/orch_test_fixture.h diff --git a/tests/gopher/orch/orch_test_fixture.h b/tests/gopher/orch/orch_test_fixture.h new file mode 100644 index 00000000..957c197d --- /dev/null +++ b/tests/gopher/orch/orch_test_fixture.h @@ -0,0 +1,96 @@ +#pragma once + +// Shared test fixture for gopher-orch unit tests +// Provides common dispatcher setup and async helpers + +#include "gopher/orch/orch.h" + +#include +#include +#include +#include +#include + +#include "mcp/event/libevent_dispatcher.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_; +}; From 7e90fd0e7eaba85f987a1a01f2169675ff493cf7 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:05:34 -0800 Subject: [PATCH 019/197] Add Lambda unit tests (#7) Tests for Lambda runnable: - LambdaSyncBasic: sync lambda execution - LambdaWithConfig: config parameter handling - LambdaError: error propagation --- tests/gopher/orch/lambda_test.cc | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/gopher/orch/lambda_test.cc diff --git a/tests/gopher/orch/lambda_test.cc b/tests/gopher/orch/lambda_test.cc new file mode 100644 index 00000000..dd4c2e21 --- /dev/null +++ b/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"); +} From bfc50dfb069e2801da40f1d525421f81e7eae63c Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:05:44 -0800 Subject: [PATCH 020/197] Add Sequence unit tests (#7) Tests for Sequence composition: - SequenceBasic: chaining lambdas - SequenceShortCircuit: error stops chain - SequenceEmpty: pass-through behavior --- tests/gopher/orch/sequence_test.cc | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/gopher/orch/sequence_test.cc diff --git a/tests/gopher/orch/sequence_test.cc b/tests/gopher/orch/sequence_test.cc new file mode 100644 index 00000000..5f792473 --- /dev/null +++ b/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()); +} From b91f28f35b752798b6bcaa0cf4470088bc259ccf Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:06:22 -0800 Subject: [PATCH 021/197] Add Parallel unit tests (#7) Tests for Parallel composition: - ParallelBasic: concurrent branch execution - ParallelFailFast: error handling - ParallelEmpty: empty object return --- tests/gopher/orch/parallel_test.cc | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/gopher/orch/parallel_test.cc diff --git a/tests/gopher/orch/parallel_test.cc b/tests/gopher/orch/parallel_test.cc new file mode 100644 index 00000000..9434cade --- /dev/null +++ b/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()); +} From 7a210bdceb08dce7d075a1d66f09db38ac9e2a8c Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:06:31 -0800 Subject: [PATCH 022/197] Add Router unit tests (#7) Tests for Router composition: - RouterBasic: condition matching and default route - RouterNoMatchNoDefault: error when no match --- tests/gopher/orch/router_test.cc | 105 +++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/gopher/orch/router_test.cc diff --git a/tests/gopher/orch/router_test.cc b/tests/gopher/orch/router_test.cc new file mode 100644 index 00000000..a9264a16 --- /dev/null +++ b/tests/gopher/orch/router_test.cc @@ -0,0 +1,105 @@ +// 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); +} From c0a0dff72a36587bdc9d105d1145786ae95bd025 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:06:42 -0800 Subject: [PATCH 023/197] Add Retry unit tests (#7) Tests for Retry resilience pattern: - RetrySuccess: immediate success return - RetryEventualSuccess: success after failures - RetryExhausted: max attempts reached --- tests/gopher/orch/retry_test.cc | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/gopher/orch/retry_test.cc diff --git a/tests/gopher/orch/retry_test.cc b/tests/gopher/orch/retry_test.cc new file mode 100644 index 00000000..ee07359a --- /dev/null +++ b/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); +} From ee6b2ce19002ec2ef87987557804dc22b03e55e2 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:07:00 -0800 Subject: [PATCH 024/197] Add Timeout unit tests (#7) Tests for Timeout resilience pattern: - TimeoutSuccess: completion before timeout - TimeoutExpired: TIMEOUT error when exceeded --- tests/gopher/orch/timeout_test.cc | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/gopher/orch/timeout_test.cc diff --git a/tests/gopher/orch/timeout_test.cc b/tests/gopher/orch/timeout_test.cc new file mode 100644 index 00000000..382c67be --- /dev/null +++ b/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); +} From b6adebb809e303905fd1aeeec131372d44e8c49f Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:07:19 -0800 Subject: [PATCH 025/197] Add Fallback unit tests (#7) Tests for Fallback resilience pattern: - FallbackPrimarySuccess: primary succeeds - FallbackUsed: fallback chain traversal - FallbackExhausted: all options failed --- tests/gopher/orch/fallback_test.cc | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/gopher/orch/fallback_test.cc diff --git a/tests/gopher/orch/fallback_test.cc b/tests/gopher/orch/fallback_test.cc new file mode 100644 index 00000000..9f78bf31 --- /dev/null +++ b/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); +} From 52419b07daf888266380b9b45b6d6cf6f281e292 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:07:58 -0800 Subject: [PATCH 026/197] Add CircuitBreaker unit tests (#7) Tests for CircuitBreaker resilience pattern: - CircuitBreakerClosed: normal operation - CircuitBreakerOpens: threshold failures - CircuitBreakerReset: manual reset --- tests/gopher/orch/circuit_breaker_test.cc | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/gopher/orch/circuit_breaker_test.cc diff --git a/tests/gopher/orch/circuit_breaker_test.cc b/tests/gopher/orch/circuit_breaker_test.cc new file mode 100644 index 00000000..7a8bf0a1 --- /dev/null +++ b/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); +} From 789c09a8eea08672936df3ba75e8c4c309111973 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:08:07 -0800 Subject: [PATCH 027/197] Add MockServer unit tests (#7) Tests for MockServer: - MockServerBasic: connect, list tools, call tool - MockServerCustomHandler: dynamic handlers - MockServerToolNotFound: missing tool handling - MockServerError: error response --- tests/gopher/orch/mock_server_test.cc | 105 ++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/gopher/orch/mock_server_test.cc diff --git a/tests/gopher/orch/mock_server_test.cc b/tests/gopher/orch/mock_server_test.cc new file mode 100644 index 00000000..ccb576a4 --- /dev/null +++ b/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, ToolListCallback 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"); +} From 3248291884621543b1290c135a6c94315aa6304e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:08:17 -0800 Subject: [PATCH 028/197] Add integration tests for orch framework (#7) Tests combining multiple components: - SequenceWithServer: workflow with server tools - ParallelWithServerTools: concurrent tool calls --- tests/gopher/orch/integration_test.cc | 81 +++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/gopher/orch/integration_test.cc diff --git a/tests/gopher/orch/integration_test.cc b/tests/gopher/orch/integration_test.cc new file mode 100644 index 00000000..e33042e0 --- /dev/null +++ b/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"); +} From bf1a87559312fdcf4e94448a25cab09b3f8e8806 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:08:51 -0800 Subject: [PATCH 029/197] Refactor tests into independent component files (#7) Split monolithic orch_test.cc into component-based test files: - lambda_test.cc, sequence_test.cc, parallel_test.cc - router_test.cc, retry_test.cc, timeout_test.cc - fallback_test.cc, circuit_breaker_test.cc - mock_server_test.cc, integration_test.cc All tests share orch_test_fixture.h for common infrastructure. --- tests/CMakeLists.txt | 15 +- tests/gopher/orch/orch_test.cc | 513 --------------------------------- 2 files changed, 13 insertions(+), 515 deletions(-) delete mode 100644 tests/gopher/orch/orch_test.cc diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c5cb5f6d..83298899 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,9 +10,18 @@ set(ORCH_CORE_TEST_SOURCES orch/hello_test.cpp ) -# New gopher/orch framework tests +# New gopher/orch framework tests - split by component set(ORCH_FRAMEWORK_TEST_SOURCES - gopher/orch/orch_test.cc + 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/mock_server_test.cc + gopher/orch/integration_test.cc ) # Helper function to create orch test executables @@ -35,6 +44,7 @@ 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 ${GOPHER_MCP_INCLUDE_DIR} ) @@ -77,6 +87,7 @@ 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 ${GOPHER_MCP_INCLUDE_DIR} ) diff --git a/tests/gopher/orch/orch_test.cc b/tests/gopher/orch/orch_test.cc deleted file mode 100644 index b06ee6ca..00000000 --- a/tests/gopher/orch/orch_test.cc +++ /dev/null @@ -1,513 +0,0 @@ -// Unit tests for gopher-orch framework -// Tests core components: Runnable, Lambda, Sequence, Parallel, MockServer - -#include "gopher/orch/orch.h" - -#include -#include -#include -#include -#include - -#include "mcp/event/libevent_dispatcher.h" - -#include "gtest/gtest.h" - -using namespace gopher::orch; -using namespace gopher::orch::core; -using namespace gopher::orch::composition; -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_; -}; - -// ============================================================================= -// 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"); -} - -// ============================================================================= -// 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()); -} - -// ============================================================================= -// 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()); -} - -// ============================================================================= -// 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, ToolListCallback 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"); -} - -// ============================================================================= -// 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"); -} - -// Main -int main(int argc, char** argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} From 8edacb4f30936f9311a6f03309cad5e19865f29a Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 21:10:19 -0800 Subject: [PATCH 030/197] make format code to apply clang-format (#7) --- .../gopher/orch/resilience/circuit_breaker.h | 7 ++-- include/gopher/orch/resilience/fallback.h | 3 +- include/gopher/orch/resilience/retry.h | 22 +++++------ include/gopher/orch/resilience/timeout.h | 16 ++++---- tests/gopher/orch/circuit_breaker_test.cc | 8 ++-- tests/gopher/orch/orch_test_fixture.h | 3 +- tests/gopher/orch/router_test.cc | 37 +++++++++++-------- 7 files changed, 49 insertions(+), 47 deletions(-) diff --git a/include/gopher/orch/resilience/circuit_breaker.h b/include/gopher/orch/resilience/circuit_breaker.h index 9f743707..9b9784b5 100644 --- a/include/gopher/orch/resilience/circuit_breaker.h +++ b/include/gopher/orch/resilience/circuit_breaker.h @@ -134,8 +134,7 @@ class CircuitBreaker : public Runnable { // Factory method static std::shared_ptr> create( - RunnablePtr inner, - CircuitBreakerPolicy policy = CircuitBreakerPolicy()) { + RunnablePtr inner, CircuitBreakerPolicy policy = CircuitBreakerPolicy()) { return std::make_shared>(std::move(inner), std::move(policy)); } @@ -214,8 +213,8 @@ class CircuitBreaker : public Runnable { // 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()); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()); return static_cast(ms.count()); } diff --git a/include/gopher/orch/resilience/fallback.h b/include/gopher/orch/resilience/fallback.h index 59758bab..301587f9 100644 --- a/include/gopher/orch/resilience/fallback.h +++ b/include/gopher/orch/resilience/fallback.h @@ -57,8 +57,7 @@ class Fallback : public Runnable { // Factory method static std::shared_ptr> create( - RunnablePtr primary, - std::vector fallbacks) { + RunnablePtr primary, std::vector fallbacks) { return std::make_shared>(std::move(primary), std::move(fallbacks)); } diff --git a/include/gopher/orch/resilience/retry.h b/include/gopher/orch/resilience/retry.h index d108fd9b..919333d6 100644 --- a/include/gopher/orch/resilience/retry.h +++ b/include/gopher/orch/resilience/retry.h @@ -26,11 +26,11 @@ 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 + 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; @@ -109,8 +109,8 @@ class Retry : public Runnable { Dispatcher& dispatcher, Callback callback, uint32_t attempt) { - auto self = - std::static_pointer_cast>(this->shared_from_this()); + auto self = std::static_pointer_cast>( + this->shared_from_this()); auto input_copy = input; // Copy for potential retry inner_->invoke( @@ -165,8 +165,8 @@ class Retry : public Runnable { 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); + 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)) { @@ -198,8 +198,8 @@ std::shared_ptr> withRetry(std::shared_ptr> inner, } // Factory for JSON retry -inline std::shared_ptr withRetry(JsonRunnablePtr inner, - RetryPolicy policy = RetryPolicy()) { +inline std::shared_ptr withRetry( + JsonRunnablePtr inner, RetryPolicy policy = RetryPolicy()) { return JsonRetry::create(std::move(inner), std::move(policy)); } diff --git a/include/gopher/orch/resilience/timeout.h b/include/gopher/orch/resilience/timeout.h index abbd4715..bbe7e8cf 100644 --- a/include/gopher/orch/resilience/timeout.h +++ b/include/gopher/orch/resilience/timeout.h @@ -46,9 +46,8 @@ class Timeout : public Runnable { // 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 = dispatcher.createTimer( + [state, &dispatcher]() { state->onTimeout(dispatcher); }); state->timer->enableTimer(std::chrono::milliseconds(timeout_ms_)); // Invoke inner runnable @@ -84,9 +83,10 @@ class Timeout : public Runnable { } // 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)); - }); + dispatcher.post( + [cb = std::move(cb), result = std::move(result)]() mutable { + cb(std::move(result)); + }); } // else: timeout already fired, discard result } @@ -114,8 +114,8 @@ using JsonTimeout = Timeout; // Factory function for creating timeout wrapper template -std::shared_ptr> withTimeout(std::shared_ptr> inner, - uint64_t timeout_ms) { +std::shared_ptr> withTimeout( + std::shared_ptr> inner, uint64_t timeout_ms) { return Timeout::create(std::move(inner), timeout_ms); } diff --git a/tests/gopher/orch/circuit_breaker_test.cc b/tests/gopher/orch/circuit_breaker_test.cc index 7a8bf0a1..40117486 100644 --- a/tests/gopher/orch/circuit_breaker_test.cc +++ b/tests/gopher/orch/circuit_breaker_test.cc @@ -49,10 +49,10 @@ TEST_F(OrchTest, CircuitBreakerOpens) { // 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)); - }); + auto result = runToCompletionResult([&](Dispatcher& d, + JsonCallback cb_fn) { + cb->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb_fn)); + }); EXPECT_TRUE(mcp::holds_alternative(result)); } diff --git a/tests/gopher/orch/orch_test_fixture.h b/tests/gopher/orch/orch_test_fixture.h index 957c197d..752448bd 100644 --- a/tests/gopher/orch/orch_test_fixture.h +++ b/tests/gopher/orch/orch_test_fixture.h @@ -3,8 +3,6 @@ // Shared test fixture for gopher-orch unit tests // Provides common dispatcher setup and async helpers -#include "gopher/orch/orch.h" - #include #include #include @@ -13,6 +11,7 @@ #include "mcp/event/libevent_dispatcher.h" +#include "gopher/orch/orch.h" #include "gtest/gtest.h" using namespace gopher::orch; diff --git a/tests/gopher/orch/router_test.cc b/tests/gopher/orch/router_test.cc index a9264a16..a7fbb128 100644 --- a/tests/gopher/orch/router_test.cc +++ b/tests/gopher/orch/router_test.cc @@ -34,23 +34,28 @@ TEST_F(OrchTest, RouterBasic) { }, "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(); + 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)); - }); + 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); @@ -59,10 +64,10 @@ TEST_F(OrchTest, RouterBasic) { JsonValue negativeInput = JsonValue::object(); negativeInput["value"] = JsonValue(-10); - JsonValue result2 = - runToCompletion([&](Dispatcher& d, JsonCallback cb) { - routerRunnable->invoke(negativeInput, RunnableConfig(), d, std::move(cb)); - }); + JsonValue result2 = runToCompletion([&](Dispatcher& d, + JsonCallback cb) { + routerRunnable->invoke(negativeInput, RunnableConfig(), d, std::move(cb)); + }); EXPECT_EQ(result2["type"].getString(), "negative"); From d6df7ce7a9bd4e6e8a5f717ff6b5feb780970143 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 22:11:18 -0800 Subject: [PATCH 031/197] Add StateGraph (Pregel model) implementation (#9) Implements stateful workflow graphs inspired by LangGraph. Uses the Pregel model (Bulk Synchronous Parallel) with PLAN-EXECUTE-UPDATE phases. Components: - GraphState: Container for state channels with JSON serialization and version tracking for change detection - GraphNode: Async node wrapper supporting both JsonRunnable and sync lambdas - StateGraph: Builder for workflow graphs with direct and conditional edges - CompiledStateGraph: Executable graph implementing Runnable Features: - Direct edges: addEdge("from", "to") for unconditional transitions - Conditional edges: addConditionalEdge with routing function - Maximum iteration limit to prevent infinite loops - C++14 compatible with static method for END sentinel --- include/gopher/orch/graph/state_graph.h | 346 ++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 include/gopher/orch/graph/state_graph.h diff --git a/include/gopher/orch/graph/state_graph.h b/include/gopher/orch/graph/state_graph.h new file mode 100644 index 00000000..920f196a --- /dev/null +++ b/include/gopher/orch/graph/state_graph.h @@ -0,0 +1,346 @@ +#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 + +#include +#include +#include +#include + +#include "gopher/orch/core/runnable.h" + +namespace gopher { +namespace orch { +namespace graph { + +using namespace gopher::orch::core; + +// ============================================================================= +// GraphState - Container for all state channels +// ============================================================================= + +class GraphState { + public: + // Set a value by key + void set(const std::string& key, const JsonValue& value) { + 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 + uint64_t version(const std::string& key) const { + auto it = versions_.find(key); + return it != versions_.end() ? it->second : 0; + } + + // 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 + void merge(const GraphState& other) { + for (const auto& entry : other.channels_) { + channels_[entry.first] = entry.second; + versions_[entry.first]++; + } + } + + private: + std::map channels_; + std::map versions_; +}; + +// Callback type for graph node completion +using GraphStateCallback = std::function)>; + +// ============================================================================= +// 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_; +}; + +// Forward declaration +class CompiledStateGraph; + +// ============================================================================= +// StateGraph - Builder for stateful workflow graphs +// ============================================================================= + +class StateGraph { + public: + // Condition function that returns the next node name + using EdgeCondition = std::function; + + // Special node name for 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; + } + + StateGraph() = default; + + // Add a node with a JsonRunnable + 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 result into state + 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 sync lambda function + 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 + 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 error: ") + e.what())); + } + }); + }; + + nodes_[name] = std::make_shared(name, std::move(node_func)); + return *this; + } + + // Add a direct edge (always transitions) + StateGraph& addEdge(const std::string& from, const std::string& to) { + edges_[from] = to; + return *this; + } + + // Add a conditional edge (transitions based on state) + StateGraph& addConditionalEdge(const std::string& from, + EdgeCondition condition) { + conditional_edges_[from] = std::move(condition); + return *this; + } + + // Set the entry point + StateGraph& setEntryPoint(const std::string& node) { + entry_point_ = node; + return *this; + } + + // Compile into executable graph + std::shared_ptr compile(); + + private: + std::map> nodes_; + std::map edges_; + std::map conditional_edges_; + std::string entry_point_; + + friend class CompiledStateGraph; +}; + +// ============================================================================= +// CompiledStateGraph - Executable state graph +// ============================================================================= + +class CompiledStateGraph : public Runnable { + public: + static constexpr size_t MAX_ITERATIONS = 100; + + explicit CompiledStateGraph(const StateGraph& graph) + : nodes_(graph.nodes_), + edges_(graph.edges_), + conditional_edges_(graph.conditional_edges_), + entry_point_(graph.entry_point_) {} + + std::string name() const override { return "CompiledStateGraph"; } + + void invoke(const JsonValue& input, + const RunnableConfig& config, + Dispatcher& dispatcher, + Callback callback) override { + if (entry_point_.empty()) { + dispatcher.post([callback = std::move(callback)]() { + callback(makeOrchError(OrchError::INVALID_ARGUMENT, + "StateGraph entry point not set")); + }); + return; + } + + // Initialize state from input + GraphState initial_state = GraphState::fromJson(input); + + // Start execution + executeNode(entry_point_, initial_state, config, dispatcher, 0, + std::move(callback)); + } + + private: + void executeNode(const std::string& node_name, + const GraphState& state, + const RunnableConfig& config, + Dispatcher& dispatcher, + size_t iteration, + Callback callback) { + // Check termination conditions + if (node_name.empty() || node_name == StateGraph::END()) { + dispatcher.post([state, callback = std::move(callback)]() { + callback(makeSuccess(state.toJson())); + }); + return; + } + + if (iteration >= MAX_ITERATIONS) { + dispatcher.post([callback = std::move(callback)]() { + callback(makeOrchError(OrchError::INTERNAL_ERROR, + "Maximum iterations exceeded")); + }); + return; + } + + // Find the node + auto it = nodes_.find(node_name); + if (it == nodes_.end()) { + dispatcher.post([node_name, callback = std::move(callback)]() { + callback(makeOrchError(OrchError::INVALID_ARGUMENT, + "Node not found: " + node_name)); + }); + return; + } + + // Execute the node + // Use static_pointer_cast since Runnable's shared_from_this returns the + // base type + 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; + } + + // Determine next node + const auto& new_state = mcp::get(result); + std::string next_node = self->getNextNode(node_name, new_state); + + // Continue execution + self->executeNode(next_node, new_state, config, dispatcher, + iteration + 1, std::move(callback)); + }); + } + + std::string getNextNode(const std::string& from, + const GraphState& state) const { + // Check conditional edges first + 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 StateGraph::END(); + } + + std::map> nodes_; + std::map edges_; + std::map conditional_edges_; + std::string entry_point_; +}; + +inline std::shared_ptr StateGraph::compile() { + return std::make_shared(*this); +} + +} // namespace graph +} // namespace orch +} // namespace gopher From bcacf3ad6266637567d0d3e01e43b5131daa97a8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 22:13:12 -0800 Subject: [PATCH 032/197] Add StateMachine (FSM) implementation (#9) Type-safe finite state machine for entity lifecycle management. Template-based design supporting custom state, event, and context types. Features: - Type-safe transitions with compile-time state/event type checking - Guards: Conditional transition validation based on state and context - Actions: Execute during transitions (after exit, before enter) - State callbacks: onEnter/onExit for state lifecycle hooks - State observers: Global notification of all state changes - Async trigger: Dispatcher integration for async state transitions - Context management: Store and update custom context data Builder pattern: - StateMachineBuilder: Fluent API for constructing state machines - makeStateMachine(): Factory function for builder creation Use cases: - Connection states (DISCONNECTED -> CONNECTING -> CONNECTED) - Workflow lifecycle (PENDING -> RUNNING -> COMPLETED) - Agent behavior (IDLE -> THINKING -> ACTING) --- include/gopher/orch/fsm/state_machine.h | 335 ++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 include/gopher/orch/fsm/state_machine.h diff --git a/include/gopher/orch/fsm/state_machine.h b/include/gopher/orch/fsm/state_machine.h new file mode 100644 index 00000000..09bff002 --- /dev/null +++ b/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 From 7227e6049195b3ab3a430d000f470f729305105b Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 22:15:09 -0800 Subject: [PATCH 033/197] Export StateGraph and StateMachine in orch.h (#9) Add includes and namespace re-exports for the new graph and FSM components: - graph/state_graph.h: StateGraph, CompiledStateGraph, GraphState, GraphNode - fsm/state_machine.h: StateMachine, StateMachineBuilder, makeStateMachine All types are now accessible at the gopher::orch namespace level. --- include/gopher/orch/orch.h | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 2537bea5..e5c8b065 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -4,7 +4,9 @@ // // Provides composable building blocks for agentic workflows: // - Runnable: Universal async operation interface -// - Sequence, Parallel: Composition patterns +// - 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 // @@ -31,6 +33,12 @@ #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" + // Server abstraction #include "gopher/orch/server/mock_server.h" #include "gopher/orch/server/server.h" @@ -90,6 +98,18 @@ using resilience::withFallback; using resilience::withRetry; using resilience::withTimeout; +// Re-export graph patterns +using graph::CompiledStateGraph; +using graph::GraphNode; +using graph::GraphState; +using graph::GraphStateCallback; +using graph::StateGraph; + +// Re-export FSM components +using fsm::makeStateMachine; +using fsm::StateMachine; +using fsm::StateMachineBuilder; + // Re-export server components using server::ConnectionCallback; using server::ConnectionState; From ca85bd27a407c23e1d7290be13d94471becf03b7 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 22:15:26 -0800 Subject: [PATCH 034/197] Add StateGraph unit tests (#9) Tests for stateful workflow graph implementation: - StateGraphBasic: Linear graph execution (start -> process -> end) - StateGraphConditionalEdge: Branching based on state values - StateGraphWithRunnable: Integration with JsonRunnable nodes - StateGraphNoEntryPoint: Error handling for missing entry point - StateGraphNodeNotFound: Error handling for invalid node reference - GraphStateOperations: State container set/get/has/version/serialization --- tests/gopher/orch/state_graph_test.cc | 194 ++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/gopher/orch/state_graph_test.cc diff --git a/tests/gopher/orch/state_graph_test.cc b/tests/gopher/orch/state_graph_test.cc new file mode 100644 index 00000000..e324d820 --- /dev/null +++ b/tests/gopher/orch/state_graph_test.cc @@ -0,0 +1,194 @@ +// 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); +} From b3d5be155d532839b10a010e14ede2b703c9a3e0 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 22:17:03 -0800 Subject: [PATCH 035/197] Add StateMachine unit tests (#9) Tests for finite state machine implementation: - StateMachineBasic: State transitions with trigger() - StateMachineInvalidTransition: Error for undefined transitions - StateMachineWithGuard: Conditional transitions with guard functions - StateMachineWithCallbacks: onEnter/onExit/onStateChange callbacks - StateMachineValidEvents: Query valid events from current state - StateMachineCanTrigger: Check if event can trigger transition - StateMachineBuilder: Fluent builder API with makeStateMachine() - StateMachineReset: Reset to specific state - StateMachineAsyncTrigger: Async transitions via dispatcher --- tests/gopher/orch/state_machine_test.cc | 226 ++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/gopher/orch/state_machine_test.cc diff --git a/tests/gopher/orch/state_machine_test.cc b/tests/gopher/orch/state_machine_test.cc new file mode 100644 index 00000000..a69357d1 --- /dev/null +++ b/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); +} From 6fc58b311ba53193172b283d2009be038b40c1fe Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sat, 27 Dec 2025 22:17:14 -0800 Subject: [PATCH 036/197] Add StateGraph and StateMachine tests to CMakeLists (#9) Include state_graph_test.cc and state_machine_test.cc in ORCH_FRAMEWORK_TEST_SOURCES for the test build. --- tests/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 83298899..f8be4491 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,8 @@ set(ORCH_FRAMEWORK_TEST_SOURCES 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/mock_server_test.cc gopher/orch/integration_test.cc ) From 03322919b0c42c9155e3d0b547c8711b933ddf0e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 10:38:41 -0800 Subject: [PATCH 037/197] Add ServerComposite for multi-server aggregation (#11) ServerComposite aggregates tools from multiple servers, providing: - Tool namespacing (server.tool format) - Tool aliasing for cleaner API - connectAll/disconnectAll for batch operations - Tool caching for performance --- include/gopher/orch/server/server_composite.h | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 include/gopher/orch/server/server_composite.h diff --git a/include/gopher/orch/server/server_composite.h b/include/gopher/orch/server/server_composite.h new file mode 100644 index 00000000..aa86d81e --- /dev/null +++ b/include/gopher/orch/server/server_composite.h @@ -0,0 +1,415 @@ +#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_) { + ToolInfo 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 From 0d1569104be7bab67aeda63b4b06bc122a26ce7c Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 10:38:50 -0800 Subject: [PATCH 038/197] Add ServerComposite unit tests (#11) --- tests/gopher/orch/server_composite_test.cc | 371 +++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 tests/gopher/orch/server_composite_test.cc diff --git a/tests/gopher/orch/server_composite_test.cc b/tests/gopher/orch/server_composite_test.cc new file mode 100644 index 00000000..28fa95d0 --- /dev/null +++ b/tests/gopher/orch/server_composite_test.cc @@ -0,0 +1,371 @@ +// 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); +} From 623a79fce553e94405c6d4fa011b311d5cac3301 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 10:39:06 -0800 Subject: [PATCH 039/197] Add MCPServer implementation (#11) MCPServer wraps gopher-mcp client to provide Server interface: - Supports stdio, HTTP+SSE, and WebSocket transports - Adapts future-based to callback-based async model - Converts MCP content blocks to JsonValue - Handles protocol initialization and tool discovery --- include/gopher/orch/server/mcp_server.h | 196 ++++++++++ src/gopher/orch/server/mcp_server.cpp | 478 ++++++++++++++++++++++++ 2 files changed, 674 insertions(+) create mode 100644 include/gopher/orch/server/mcp_server.h create mode 100644 src/gopher/orch/server/mcp_server.cpp diff --git a/include/gopher/orch/server/mcp_server.h b/include/gopher/orch/server/mcp_server.h new file mode 100644 index 00000000..2fec2a32 --- /dev/null +++ b/include/gopher/orch/server/mcp_server.h @@ -0,0 +1,196 @@ +#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 "gopher/orch/server/server.h" +#include "mcp/client/mcp_client.h" +#include "mcp/event/event_loop.h" +#include "mcp/types.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, ToolListCallback 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); + + // Handle tools listed + void onToolsListed(const mcp::ListToolsResult& tools_result); + + // Convert MCP Tool to ToolInfo + static ToolInfo toToolInfo(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/src/gopher/orch/server/mcp_server.cpp b/src/gopher/orch/server/mcp_server.cpp new file mode 100644 index 00000000..5b6cbd05 --- /dev/null +++ b/src/gopher/orch/server/mcp_server.cpp @@ -0,0 +1,478 @@ +// 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 + +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; + } + + // Initialize protocol + // Wrap future in shared_ptr to make lambda copyable for std::function + // Note: MCP client returns std::future not std::future> + auto init_future_ptr = std::make_shared>( + client_->initializeProtocol()); + + // Capture self as shared_ptr + // MCPServer inherits from Server which inherits from enable_shared_from_this + MCPServer* this_ptr = this; + auto self = std::shared_ptr( + std::static_pointer_cast(this_ptr->Server::shared_from_this())); + + // We need to wait for the future in a non-blocking way + // Post to dispatcher and handle result + dispatcher.post([self, callback, init_future_ptr, &dispatcher]() { + try { + // Wait for and get the result - future throws on error + mcp::InitializeResult init_result = init_future_ptr->get(); + self->onInitialized(dispatcher, init_result, callback); + } catch (const std::exception& e) { + self->state_ = ConnectionState::FAILED; + callback(makeOrchError( + OrchError::CONNECTION_FAILED, + std::string("Failed to initialize MCP protocol: ") + e.what())); + } + }); +} + +// Handle protocol initialization complete +void MCPServer::onInitialized( + Dispatcher& dispatcher, + const mcp::InitializeResult& init_result, + std::function)> callback) { + // 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()); + + dispatcher.post([self, callback, tools_future_ptr]() { + 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)); + } + }); +} + +// 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(toToolInfo(mcp_tool)); + } +} + +// Convert MCP Tool to ToolInfo +ToolInfo MCPServer::toToolInfo(const mcp::Tool& tool) { + ToolInfo 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(); + // 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, ToolListCallback 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 + ToolInfo 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()) { + // Create Metadata from JsonValue + mcp_args = mcp::Metadata(); + // The arguments need to be copied to Metadata + // For now, we'll pass an empty object; proper conversion needed + } + + auto self = std::static_pointer_cast(Server::shared_from_this()); + auto tool_future_ptr = std::make_shared>( + client_->callTool(name, mcp_args)); + + dispatcher.post([self, callback, tool_future_ptr]() { + try { + mcp::CallToolResult result = tool_future_ptr->get(); + if (result.isError) { + // Tool returned an error + JsonValue error_content = contentToJson(result.content); + callback(makeOrchError( + OrchError::INTERNAL_ERROR, error_content.toString())); + } else { + // Success - convert content to JsonValue + JsonValue json_result = contentToJson(result.content); + callback(makeSuccess(json_result)); + } + } catch (const std::exception& e) { + callback(makeOrchError( + OrchError::INTERNAL_ERROR, e.what())); + } + }); +} + +} // namespace server +} // namespace orch +} // namespace gopher From 0df0e0753a1a0f487438076b3bfd5a55561bca40 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 10:39:28 -0800 Subject: [PATCH 040/197] Add MCPServer unit tests (#11) --- tests/gopher/orch/mcp_server_test.cc | 100 +++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/gopher/orch/mcp_server_test.cc diff --git a/tests/gopher/orch/mcp_server_test.cc b/tests/gopher/orch/mcp_server_test.cc new file mode 100644 index 00000000..19ae9724 --- /dev/null +++ b/tests/gopher/orch/mcp_server_test.cc @@ -0,0 +1,100 @@ +// 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 From c2f54c0665968f1a241eebac875cfa46b4705dff Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 10:40:02 -0800 Subject: [PATCH 041/197] Export ServerComposite and MCPServer in orch.h (#11) --- include/gopher/orch/orch.h | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index e5c8b065..df623349 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -42,6 +42,13 @@ // Server abstraction #include "gopher/orch/server/mock_server.h" #include "gopher/orch/server/server.h" +#include "gopher/orch/server/server_composite.h" + +// MCP Server (requires gopher-mcp dependency) +// Conditionally included to avoid hard dependency +#ifdef GOPHER_ORCH_WITH_MCP +#include "gopher/orch/server/mcp_server.h" +#endif // Convenience namespace imports namespace gopher { @@ -116,11 +123,21 @@ 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::ServerToolPtr; using server::ToolInfo; using server::ToolListCallback; +using server::ToolMapping; + +// MCP Server exports (conditional) +#ifdef GOPHER_ORCH_WITH_MCP +using server::MCPServer; +using server::MCPServerConfig; +using server::MCPServerPtr; +#endif } // namespace orch } // namespace gopher From 881e882a871148461dea25ca8de114e0fdc19574 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 10:40:12 -0800 Subject: [PATCH 042/197] Add MCPServer and tests to CMakeLists (#11) --- src/CMakeLists.txt | 48 ++++++++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 ++ 2 files changed, 50 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 709369ef..d14051a0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,9 +5,19 @@ set(ORCH_CORE_SOURCES orch/hello.cpp ) +# 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.cpp + ) +endif() + # Combine all sources set(GOPHER_ORCH_SOURCES ${ORCH_CORE_SOURCES} + ${ORCH_MCP_SOURCES} ) # Build static library @@ -19,12 +29,32 @@ if(BUILD_STATIC_LIBS) $ ) + # 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} Threads::Threads ) + # Define GOPHER_ORCH_WITH_MCP to enable MCP-specific code + target_compile_definitions(gopher-orch-static PUBLIC GOPHER_ORCH_WITH_MCP) else() target_link_libraries(gopher-orch-static PUBLIC Threads::Threads @@ -58,12 +88,30 @@ if(BUILD_SHARED_LIBS) $ ) + # 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} Threads::Threads ) + # Define GOPHER_ORCH_WITH_MCP to enable MCP-specific code + target_compile_definitions(gopher-orch-shared PUBLIC GOPHER_ORCH_WITH_MCP) else() target_link_libraries(gopher-orch-shared PUBLIC Threads::Threads diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f8be4491..3ea02008 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,8 @@ set(ORCH_FRAMEWORK_TEST_SOURCES gopher/orch/state_graph_test.cc gopher/orch/state_machine_test.cc gopher/orch/mock_server_test.cc + gopher/orch/server_composite_test.cc + gopher/orch/mcp_server_test.cc gopher/orch/integration_test.cc ) From ff086bc1bdefa51351822ecb4a6b609f8bb51844 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 11:05:48 -0800 Subject: [PATCH 043/197] Add RESTServer header with config and HttpClient interface (#11) RESTServer wraps REST API endpoints as tools with: - RESTServerConfig with fluent API for endpoint mapping - Multiple auth types (Bearer, Basic, API Key) - HttpClient interface for custom HTTP backends - Path parameter substitution support --- include/gopher/orch/server/rest_server.h | 298 +++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 include/gopher/orch/server/rest_server.h diff --git a/include/gopher/orch/server/rest_server.h b/include/gopher/orch/server/rest_server.h new file mode 100644 index 00000000..7638618f --- /dev/null +++ b/include/gopher/orch/server/rest_server.h @@ -0,0 +1,298 @@ +#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}" + ToolInfo 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 ToolInfo& 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, ToolListCallback 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); +} + +} // namespace server +} // namespace orch +} // namespace gopher From a48310333a3073013aaddcef112e5191f9e75773 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 11:06:01 -0800 Subject: [PATCH 044/197] Add RESTServer implementation (#11) Implements Server interface for REST API access: - URL building with path parameter substitution - Header management with auth support - DefaultHttpClient stub (inject custom for production) --- src/gopher/orch/server/rest_server.cpp | 409 +++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/gopher/orch/server/rest_server.cpp diff --git a/src/gopher/orch/server/rest_server.cpp b/src/gopher/orch/server/rest_server.cpp new file mode 100644 index 00000000..a530326d --- /dev/null +++ b/src/gopher/orch/server/rest_server.cpp @@ -0,0 +1,409 @@ +// 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 provides a stub implementation. +// For actual HTTP requests, inject a custom HttpClient implementation +// that uses a proper HTTP library (e.g., libcurl, boost::beast). +// +// The stub returns an error indicating that no HTTP backend is configured. +// This is by design - the RESTServer is meant to be used with a custom +// HttpClient for production use, or with MockHttpClient for testing. +class DefaultHttpClient::Impl { + public: + Impl() = default; + + void request(HttpMethod method, + const std::string& url, + const std::map& headers, + const std::string& body, + Dispatcher& dispatcher, + HttpClient::ResponseCallback callback) { + (void)method; + (void)url; + (void)headers; + (void)body; + + // Return an error indicating no HTTP backend is configured + // In production, inject a custom HttpClient implementation + dispatcher.post([callback]() { + callback(Result( + Error(OrchError::INTERNAL_ERROR, + "DefaultHttpClient: No HTTP backend configured. " + "Please inject a custom HttpClient implementation."))); + }); + } +}; + +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, ToolListCallback 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 From c04554f95723d0079b16ad61a56402be06f4b66d Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 11:06:27 -0800 Subject: [PATCH 045/197] Add RESTServer unit tests (#11) Tests configuration, connection, tool invocation, path params, authentication, error handling, and ServerComposite integration. --- tests/gopher/orch/rest_server_test.cc | 504 ++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 tests/gopher/orch/rest_server_test.cc diff --git a/tests/gopher/orch/rest_server_test.cc b/tests/gopher/orch/rest_server_test.cc new file mode 100644 index 00000000..818c999f --- /dev/null +++ b/tests/gopher/orch/rest_server_test.cc @@ -0,0 +1,504 @@ +// Unit tests for RESTServer +// +// Tests REST server configuration, URL building, and integration with +// ServerComposite. Uses a mock HTTP client for isolated testing. + +#include "orch_test_fixture.h" + +#include "gopher/orch/server/rest_server.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, ToolListCallback 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")); +} From c9af41bfb758b2f33386032f8a079d2c1e7400a4 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 11:07:16 -0800 Subject: [PATCH 046/197] Export RESTServer in orch.h (#11) --- include/gopher/orch/orch.h | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index df623349..8bf3a080 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -44,10 +44,11 @@ #include "gopher/orch/server/server.h" #include "gopher/orch/server/server_composite.h" -// MCP Server (requires gopher-mcp dependency) +// 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 // Convenience namespace imports @@ -132,11 +133,18 @@ using server::ToolInfo; using server::ToolListCallback; using server::ToolMapping; -// MCP Server exports (conditional) +// MCP Server and REST Server exports (conditional) #ifdef GOPHER_ORCH_WITH_MCP using server::MCPServer; using server::MCPServerConfig; using server::MCPServerPtr; +using server::RESTServer; +using server::RESTServerConfig; +using server::RESTServerPtr; +using server::makeRESTServer; +using server::HttpMethod; +using server::HttpClient; +using server::HttpResponse; #endif } // namespace orch From 7262aae1bbe4323efe6cf51476fbc774f27a1f1b Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 11:07:26 -0800 Subject: [PATCH 047/197] Add RESTServer to CMakeLists (#11) --- src/CMakeLists.txt | 1 + tests/CMakeLists.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d14051a0..136c8b58 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,7 @@ set(ORCH_MCP_SOURCES "") if(NOT BUILD_WITHOUT_GOPHER_MCP) set(ORCH_MCP_SOURCES gopher/orch/server/mcp_server.cpp + gopher/orch/server/rest_server.cpp ) endif() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3ea02008..93341ae6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ set(ORCH_FRAMEWORK_TEST_SOURCES 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 ) From 91626e3473ab2d5f276d82634b9df42ae466e047 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 11:11:01 -0800 Subject: [PATCH 048/197] make format code to apply clang-format (#11) --- include/gopher/orch/orch.h | 8 +- include/gopher/orch/server/mcp_server.h | 29 ++++--- include/gopher/orch/server/rest_server.h | 82 ++++++++++++------- include/gopher/orch/server/server_composite.h | 27 +++--- src/gopher/orch/server/mcp_server.cpp | 64 +++++++-------- src/gopher/orch/server/rest_server.cpp | 50 ++++++----- tests/gopher/orch/mcp_server_test.cc | 34 ++++---- tests/gopher/orch/rest_server_test.cc | 67 +++++++++------ tests/gopher/orch/server_composite_test.cc | 9 +- 9 files changed, 211 insertions(+), 159 deletions(-) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 8bf3a080..cc740c1f 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -135,16 +135,16 @@ 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; -using server::makeRESTServer; -using server::HttpMethod; -using server::HttpClient; -using server::HttpResponse; #endif } // namespace orch diff --git a/include/gopher/orch/server/mcp_server.h b/include/gopher/orch/server/mcp_server.h index 2fec2a32..0df4018f 100644 --- a/include/gopher/orch/server/mcp_server.h +++ b/include/gopher/orch/server/mcp_server.h @@ -8,7 +8,8 @@ // Usage: // MCPServerConfig config; // config.name = "my-mcp-server"; -// config.transport = MCPServerConfig::StdioTransport{"npx", {"-y", "server"}}; +// config.transport = MCPServerConfig::StdioTransport{"npx", {"-y", +// "server"}}; // // MCPServer::create(config, dispatcher, [](Result result) { // if (result.isOk()) { @@ -24,11 +25,12 @@ #include #include -#include "gopher/orch/server/server.h" #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 { @@ -44,29 +46,30 @@ struct MCPServerConfig { // 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) + 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 + 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 + 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 + // 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; diff --git a/include/gopher/orch/server/rest_server.h b/include/gopher/orch/server/rest_server.h index 7638618f..66a0089b 100644 --- a/include/gopher/orch/server/rest_server.h +++ b/include/gopher/orch/server/rest_server.h @@ -3,7 +3,8 @@ // 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. +// Each tool maps to an HTTP endpoint with configurable method, path, and +// schema. // // Usage: // RESTServerConfig config; @@ -50,26 +51,41 @@ enum class HttpMethod { // 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"; + 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; + 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; } @@ -80,14 +96,19 @@ struct RESTToolEndpoint { ToolInfo info; // Tool metadata // Request body handling - bool send_body = true; // Send input JSON as request body (for POST/PUT/PATCH) + 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) + std::string response_json_path; // JSONPath to extract from response (empty = + // use whole response) RESTToolEndpoint() = default; RESTToolEndpoint(HttpMethod m, const std::string& p, const ToolInfo& i) - : method(m), path(p), info(i), send_body(m != HttpMethod::GET && m != HttpMethod::DELETE_) {} + : method(m), + path(p), + info(i), + send_body(m != HttpMethod::GET && m != HttpMethod::DELETE_) {} }; // Configuration for REST server connection @@ -103,10 +124,10 @@ struct RESTServerConfig { 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 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; @@ -143,7 +164,8 @@ struct RESTServerConfig { return addTool(name, parseHttpMethod(method), path, description); } - RESTServerConfig& setHeader(const std::string& name, const std::string& value) { + RESTServerConfig& setHeader(const std::string& name, + const std::string& value) { default_headers[name] = value; return *this; } @@ -154,14 +176,16 @@ struct RESTServerConfig { return *this; } - RESTServerConfig& setBasicAuth(const std::string& username, const std::string& password) { + 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") { + 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; @@ -223,7 +247,8 @@ class RESTServer : public Server { ConnectionState connectionState() const override { return state_; } void connect(Dispatcher& dispatcher, ConnectionCallback callback) override; - void disconnect(Dispatcher& dispatcher, std::function callback) override; + void disconnect(Dispatcher& dispatcher, + std::function callback) override; void listTools(Dispatcher& dispatcher, ToolListCallback callback) override; @@ -247,7 +272,8 @@ class RESTServer : public Server { void setDefaultHeader(const std::string& name, const std::string& value); private: - explicit RESTServer(const RESTServerConfig& config, HttpClientPtr http_client); + 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; diff --git a/include/gopher/orch/server/server_composite.h b/include/gopher/orch/server/server_composite.h index aa86d81e..5af87de0 100644 --- a/include/gopher/orch/server/server_composite.h +++ b/include/gopher/orch/server/server_composite.h @@ -39,12 +39,13 @@ 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) + 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, + ToolMapping(const std::string& server, + const std::string& tool, const std::string& alias_name = "") : server_name(server), tool_name(tool), alias(alias_name) {} }; @@ -83,8 +84,7 @@ class ServerComposite : public std::enable_shared_from_this { // Add a server with tool aliases ServerComposite& addServerWithAliases( - ServerPtr server, - const std::map& aliases); + ServerPtr server, const std::map& aliases); // Add a specific tool with optional alias ServerComposite& addTool(ServerPtr server, @@ -126,8 +126,7 @@ class ServerComposite : public std::enable_shared_from_this { std::function)> callback); // Disconnect all servers - void disconnectAll(Dispatcher& dispatcher, - std::function callback); + void disconnectAll(Dispatcher& dispatcher, std::function callback); private: explicit ServerComposite(const std::string& name) : name_(name) {} @@ -200,9 +199,8 @@ inline ServerComposite& ServerComposite::addServer( servers_[server_name] = server; for (const auto& tool_name : tool_names) { - std::string exposed = namespace_tools - ? server_name + "." + tool_name - : tool_name; + std::string exposed = + namespace_tools ? server_name + "." + tool_name : tool_name; tool_mappings_[exposed] = {server_name, tool_name}; } @@ -210,8 +208,7 @@ inline ServerComposite& ServerComposite::addServer( } inline ServerComposite& ServerComposite::addServerWithAliases( - ServerPtr server, - const std::map& aliases) { + ServerPtr server, const std::map& aliases) { std::string server_name = server->name(); servers_[server_name] = server; @@ -368,8 +365,8 @@ inline void ServerComposite::connectAll( for (const auto& entry : servers_) { entry.second->connect(dispatcher, [pending, has_error, first_error, - callback, - &dispatcher](Result result) { + callback, &dispatcher]( + Result result) { if (core::isError(result) && !has_error->exchange(true)) { *first_error = core::getError(result); } diff --git a/src/gopher/orch/server/mcp_server.cpp b/src/gopher/orch/server/mcp_server.cpp index 5b6cbd05..888e78b8 100644 --- a/src/gopher/orch/server/mcp_server.cpp +++ b/src/gopher/orch/server/mcp_server.cpp @@ -1,7 +1,7 @@ // MCPServer implementation // -// Wraps the gopher-mcp client to implement the protocol-agnostic Server interface. -// All callbacks are invoked in dispatcher thread context. +// 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" @@ -126,16 +126,14 @@ void MCPServer::create(const MCPServerConfig& config, } else { // Return immediately, user must call connect() MCPServerPtr server_copy = server; - dispatcher.post([callback, server_copy]() { - callback(makeSuccess(server_copy)); - }); + dispatcher.post( + [callback, server_copy]() { callback(makeSuccess(server_copy)); }); } } // Initialize connection -void MCPServer::initialize( - Dispatcher& dispatcher, - std::function)> callback) { +void MCPServer::initialize(Dispatcher& dispatcher, + std::function)> callback) { state_ = ConnectionState::CONNECTING; // Create MCP client configuration @@ -174,7 +172,8 @@ void MCPServer::initialize( if (!config_.stdio_transport.args.empty()) { oss << "?"; for (size_t i = 0; i < config_.stdio_transport.args.size(); ++i) { - if (i > 0) oss << "&"; + if (i > 0) + oss << "&"; oss << config_.stdio_transport.args[i]; } } @@ -207,10 +206,11 @@ void MCPServer::initialize( client_->initializeProtocol()); // Capture self as shared_ptr - // MCPServer inherits from Server which inherits from enable_shared_from_this + // MCPServer inherits from Server which inherits from + // enable_shared_from_this MCPServer* this_ptr = this; - auto self = std::shared_ptr( - std::static_pointer_cast(this_ptr->Server::shared_from_this())); + auto self = std::shared_ptr(std::static_pointer_cast( + this_ptr->Server::shared_from_this())); // We need to wait for the future in a non-blocking way // Post to dispatcher and handle result @@ -241,8 +241,8 @@ void MCPServer::onInitialized( // List available tools auto self = std::static_pointer_cast(Server::shared_from_this()); - auto tools_future_ptr = std::make_shared>( - client_->listTools()); + auto tools_future_ptr = + std::make_shared>(client_->listTools()); dispatcher.post([self, callback, tools_future_ptr]() { try { @@ -320,9 +320,8 @@ void MCPServer::connect(Dispatcher& dispatcher, ConnectionCallback callback) { if (state_ == ConnectionState::CONNECTING) { // Already connecting, queue the callback - pending_on_connect_.push_back([callback]() { - callback(makeSuccess(nullptr)); - }); + pending_on_connect_.push_back( + [callback]() { callback(makeSuccess(nullptr)); }); return; } @@ -362,8 +361,8 @@ void MCPServer::disconnect(Dispatcher& dispatcher, void MCPServer::listTools(Dispatcher& dispatcher, ToolListCallback callback) { if (!this->Server::isConnected()) { dispatcher.post([callback]() { - callback(makeOrchError>( - OrchError::NOT_CONNECTED, "Server is not connected")); + callback(makeOrchError>(OrchError::NOT_CONNECTED, + "Server is not connected")); }); return; } @@ -371,16 +370,15 @@ void MCPServer::listTools(Dispatcher& dispatcher, ToolListCallback callback) { // Return cached tools if available if (!tools_.empty()) { auto tools_copy = tools_; - dispatcher.post([callback, tools_copy]() { - callback(makeSuccess(tools_copy)); - }); + 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()); + auto tools_future_ptr = + std::make_shared>(client_->listTools()); dispatcher.post([self, callback, tools_future_ptr]() { try { @@ -388,8 +386,8 @@ void MCPServer::listTools(Dispatcher& dispatcher, ToolListCallback callback) { self->onToolsListed(tools_result); callback(makeSuccess(self->tools_)); } catch (const std::exception& e) { - callback(makeOrchError>( - OrchError::INTERNAL_ERROR, e.what())); + callback(makeOrchError>(OrchError::INTERNAL_ERROR, + e.what())); } }); } @@ -419,7 +417,8 @@ JsonRunnablePtr MCPServer::tool(const std::string& name) { } // Create ServerTool wrapper - auto tool_ptr = std::make_shared(Server::shared_from_this(), info); + auto tool_ptr = + std::make_shared(Server::shared_from_this(), info); tool_cache_[name] = tool_ptr; return tool_ptr; } @@ -434,8 +433,8 @@ void MCPServer::callTool(const std::string& name, if (!this->Server::isConnected()) { dispatcher.post([callback]() { - callback(makeOrchError( - OrchError::NOT_CONNECTED, "Server is not connected")); + callback(makeOrchError(OrchError::NOT_CONNECTED, + "Server is not connected")); }); return; } @@ -459,16 +458,15 @@ void MCPServer::callTool(const std::string& name, if (result.isError) { // Tool returned an error JsonValue error_content = contentToJson(result.content); - callback(makeOrchError( - OrchError::INTERNAL_ERROR, error_content.toString())); + callback(makeOrchError(OrchError::INTERNAL_ERROR, + error_content.toString())); } else { // Success - convert content to JsonValue JsonValue json_result = contentToJson(result.content); callback(makeSuccess(json_result)); } } catch (const std::exception& e) { - callback(makeOrchError( - OrchError::INTERNAL_ERROR, e.what())); + callback(makeOrchError(OrchError::INTERNAL_ERROR, e.what())); } }); } diff --git a/src/gopher/orch/server/rest_server.cpp b/src/gopher/orch/server/rest_server.cpp index a530326d..685f1dd6 100644 --- a/src/gopher/orch/server/rest_server.cpp +++ b/src/gopher/orch/server/rest_server.cpp @@ -23,7 +23,7 @@ std::atomic g_rest_id_counter{0}; // Parse URL into components struct UrlComponents { - std::string scheme; // http or https + std::string scheme; // http or https std::string host; uint16_t port = 0; std::string path; @@ -85,8 +85,10 @@ std::string base64Encode(const std::string& input) { 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]); + 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]; @@ -159,12 +161,13 @@ 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) { +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)); } @@ -178,7 +181,8 @@ std::string RESTServer::generateId() { return oss.str(); } -RESTServer::RESTServer(const RESTServerConfig& config, HttpClientPtr http_client) +RESTServer::RESTServer(const RESTServerConfig& config, + HttpClientPtr http_client) : id_(generateId()), config_(config), http_client_(std::move(http_client)) {} @@ -191,8 +195,9 @@ RESTServer::Ptr RESTServer::create(const RESTServerConfig& config) { } RESTServer::Ptr RESTServer::create(const RESTServerConfig& config, - HttpClientPtr http_client) { - return std::shared_ptr(new RESTServer(config, std::move(http_client))); + HttpClientPtr http_client) { + return std::shared_ptr( + new RESTServer(config, std::move(http_client))); } void RESTServer::connect(Dispatcher& dispatcher, ConnectionCallback callback) { @@ -207,9 +212,8 @@ void RESTServer::connect(Dispatcher& dispatcher, ConnectionCallback callback) { } state_ = ConnectionState::CONNECTED; - dispatcher.post([callback]() { - callback(core::makeSuccess(nullptr)); - }); + dispatcher.post( + [callback]() { callback(core::makeSuccess(nullptr)); }); } void RESTServer::disconnect(Dispatcher& dispatcher, @@ -249,7 +253,8 @@ JsonRunnablePtr RESTServer::tool(const std::string& name) { } // Create ServerTool wrapper - auto tool_ptr = std::make_shared(shared_from_this(), tool_it->second.info); + auto tool_ptr = + std::make_shared(shared_from_this(), tool_it->second.info); tool_cache_[name] = tool_ptr; return tool_ptr; } @@ -326,7 +331,7 @@ void RESTServer::callTool(const std::string& name, } std::string RESTServer::buildUrl(const std::string& path, - const JsonValue& args) const { + const JsonValue& args) const { std::string url = config_.base_url; // Replace path parameters @@ -338,7 +343,8 @@ std::string RESTServer::buildUrl(const std::string& path, std::string final_path; size_t last_pos = 0; - while (std::regex_search(search_start, result_path.cend(), match, param_regex)) { + while ( + std::regex_search(search_start, result_path.cend(), match, param_regex)) { std::string param_name = match[1].str(); std::string replacement; @@ -356,7 +362,8 @@ std::string RESTServer::buildUrl(const std::string& path, } } - size_t match_start = static_cast(match.position()) + (search_start - result_path.cbegin()); + 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(); @@ -378,7 +385,8 @@ std::map RESTServer::buildHeaders() const { headers["Authorization"] = "Bearer " + config_.auth.bearer_token; break; case RESTServerConfig::AuthConfig::Type::BASIC: { - std::string credentials = config_.auth.username + ":" + config_.auth.password; + std::string credentials = + config_.auth.username + ":" + config_.auth.password; headers["Authorization"] = "Basic " + base64Encode(credentials); break; } @@ -399,7 +407,7 @@ void RESTServer::setAuth(const RESTServerConfig::AuthConfig& auth) { } void RESTServer::setDefaultHeader(const std::string& name, - const std::string& value) { + const std::string& value) { std::lock_guard lock(mutex_); config_.default_headers[name] = value; } diff --git a/tests/gopher/orch/mcp_server_test.cc b/tests/gopher/orch/mcp_server_test.cc index 19ae9724..8f8f11ba 100644 --- a/tests/gopher/orch/mcp_server_test.cc +++ b/tests/gopher/orch/mcp_server_test.cc @@ -1,7 +1,8 @@ // Unit tests for MCPServer // -// Tests MCPServer configuration, creation, and integration with ServerComposite. -// Note: Full integration tests require actual MCP server connections. +// Tests MCPServer configuration, creation, and integration with +// ServerComposite. Note: Full integration tests require actual MCP server +// connections. #include "orch_test_fixture.h" @@ -21,7 +22,8 @@ TEST_F(OrchTest, MCPServerConfigDefaults) { config.name = "test-server"; EXPECT_EQ(config.name, "test-server"); - EXPECT_EQ(config.transport_type, server::MCPServerConfig::TransportType::STDIO); + 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); @@ -35,7 +37,8 @@ TEST_F(OrchTest, MCPServerConfigStdioTransport) { 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.args = {"-y", + "@modelcontextprotocol/server-everything"}; config.stdio_transport.env["NODE_ENV"] = "production"; EXPECT_EQ(config.stdio_transport.command, "npx"); @@ -54,7 +57,8 @@ TEST_F(OrchTest, MCPServerConfigHttpSseTransport) { 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_EQ(config.http_sse_transport.headers["Authorization"], + "Bearer token123"); EXPECT_TRUE(config.http_sse_transport.verify_ssl); } @@ -63,12 +67,13 @@ TEST_F(OrchTest, MCPServerWithComposite) { // 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)); - }); + 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"); @@ -89,9 +94,10 @@ TEST_F(OrchTest, MCPServerWithComposite) { JsonValue input = JsonValue::object(); input["city"] = JsonValue("Seattle"); - JsonValue result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { - weatherTool->invoke(input, RunnableConfig(), d, std::move(cb)); - }); + 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"); diff --git a/tests/gopher/orch/rest_server_test.cc b/tests/gopher/orch/rest_server_test.cc index 818c999f..791362b2 100644 --- a/tests/gopher/orch/rest_server_test.cc +++ b/tests/gopher/orch/rest_server_test.cc @@ -3,10 +3,10 @@ // Tests REST server configuration, URL building, and integration with // ServerComposite. Uses a mock HTTP client for isolated testing. -#include "orch_test_fixture.h" - #include "gopher/orch/server/rest_server.h" +#include "orch_test_fixture.h" + using namespace gopher::orch::server; // ============================================================================= @@ -43,9 +43,8 @@ class MockHttpClient : public HttpClient { response.body = "{}"; } - dispatcher.post([callback, response]() { - callback(Result(response)); - }); + dispatcher.post( + [callback, response]() { callback(Result(response)); }); } // Set response for a specific URL @@ -97,7 +96,8 @@ TEST_F(OrchTest, RESTServerConfigDefaults) { TEST_F(OrchTest, RESTServerConfigFluentAPI) { RESTServerConfig config; - config.name = "fluent-api";; + config.name = "fluent-api"; + ; config.base_url = "https://api.example.com"; config.addTool("get_users", "GET", "/users", "Get all users") @@ -277,9 +277,10 @@ TEST_F(OrchTest, RESTServerCallToolGet) { // Call tool JsonValue input = JsonValue::object(); - JsonValue result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { - server->callTool("get_data", input, RunnableConfig(), d, std::move(cb)); - }); + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + server->callTool("get_data", input, RunnableConfig(), d, std::move(cb)); + }); EXPECT_EQ(result["result"].getString(), "success"); @@ -311,7 +312,8 @@ TEST_F(OrchTest, RESTServerCallToolPost) { JsonValue input = JsonValue::object(); input["name"] = JsonValue("test item"); - JsonValue result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + JsonValue result = runToCompletion([&](Dispatcher& d, + JsonCallback cb) { server->callTool("create_item", input, RunnableConfig(), d, std::move(cb)); }); @@ -319,7 +321,8 @@ TEST_F(OrchTest, RESTServerCallToolPost) { // Verify request EXPECT_EQ(mockClient->requests()[0].method, HttpMethod::POST); - EXPECT_EQ(mockClient->requests()[0].headers.at("Content-Type"), "application/json"); + EXPECT_EQ(mockClient->requests()[0].headers.at("Content-Type"), + "application/json"); EXPECT_FALSE(mockClient->requests()[0].body.empty()); } @@ -346,12 +349,14 @@ TEST_F(OrchTest, RESTServerCallToolWithPathParams) { 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)); - }); + 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"); + EXPECT_EQ(mockClient->requests()[0].url, + "http://localhost:8080/users/42/posts/123"); } TEST_F(OrchTest, RESTServerCallToolNotFound) { @@ -368,9 +373,11 @@ TEST_F(OrchTest, RESTServerCallToolNotFound) { server->connect(d, std::move(cb)); }); - auto result = runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { - server->callTool("nonexistent_tool", JsonValue::object(), RunnableConfig(), 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)); } @@ -394,9 +401,11 @@ TEST_F(OrchTest, RESTServerCallToolHttpError) { server->connect(d, std::move(cb)); }); - auto result = runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { - server->callTool("error_tool", JsonValue::object(), RunnableConfig(), 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)); } @@ -426,7 +435,8 @@ TEST_F(OrchTest, RESTServerBearerAuth) { }); runToCompletion([&](Dispatcher& d, JsonCallback cb) { - server->callTool("auth_tool", JsonValue::object(), RunnableConfig(), d, std::move(cb)); + server->callTool("auth_tool", JsonValue::object(), RunnableConfig(), d, + std::move(cb)); }); // Verify Authorization header @@ -455,11 +465,13 @@ TEST_F(OrchTest, RESTServerApiKeyAuth) { }); runToCompletion([&](Dispatcher& d, JsonCallback cb) { - server->callTool("api_tool", JsonValue::object(), RunnableConfig(), d, std::move(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"); + EXPECT_EQ(mockClient->requests()[0].headers.at("X-API-Key"), + "secret-api-key"); } // ============================================================================= @@ -496,9 +508,10 @@ TEST_F(OrchTest, RESTServerWithComposite) { EXPECT_NE(tool, nullptr); // Invoke through composite - JsonValue result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { - tool->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); - }); + JsonValue result = + runToCompletion([&](Dispatcher& d, JsonCallback cb) { + tool->invoke(JsonValue::object(), RunnableConfig(), d, std::move(cb)); + }); EXPECT_TRUE(result.contains("items")); } diff --git a/tests/gopher/orch/server_composite_test.cc b/tests/gopher/orch/server_composite_test.cc index 28fa95d0..18f54671 100644 --- a/tests/gopher/orch/server_composite_test.cc +++ b/tests/gopher/orch/server_composite_test.cc @@ -344,8 +344,10 @@ TEST_F(OrchTest, ServerCompositeListToolInfos) { // 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; + if (info.name == "server.tool1") + found_tool1 = true; + if (info.name == "server.tool2") + found_tool2 = true; } EXPECT_TRUE(found_tool1); EXPECT_TRUE(found_tool2); @@ -363,8 +365,7 @@ TEST_F(OrchTest, ServerCompositeChainedAdditions) { // Chain additions std::vector t1 = {"t1"}; std::vector t2 = {"t2"}; - composite->addServer(server1, t1, true) - .addServer(server2, t2, true); + composite->addServer(server1, t1, true).addServer(server2, t2, true); EXPECT_EQ(composite->servers().size(), 2u); EXPECT_EQ(composite->listTools().size(), 2u); From 89a04c673c9e5d59f3bf512c0b746fe787e4eed8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 12:09:31 -0800 Subject: [PATCH 049/197] Add graph_state.h with GraphState and StateChannel classes (#13) Introduces state container with channel-based state management: - StateChannel template with optional reducer support - Built-in reducers: appendArray, mergeObjects, lastWriteWins - ChannelConfig for channel configuration - Enhanced GraphState with reducer-aware set/merge/copy operations - Version tracking for change detection - JSON serialization for persistence --- include/gopher/orch/graph/graph_state.h | 276 ++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 include/gopher/orch/graph/graph_state.h diff --git a/include/gopher/orch/graph/graph_state.h b/include/gopher/orch/graph/graph_state.h new file mode 100644 index 00000000..2ba71920 --- /dev/null +++ b/include/gopher/orch/graph/graph_state.h @@ -0,0 +1,276 @@ +#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 From 1b9861d35ae7208b3ab02b34de8d62a286084ae8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 12:10:01 -0800 Subject: [PATCH 050/197] Add graph_node.h with GraphNode class (#13) Extracts GraphNode to its own header file for cleaner separation: - GraphNode wraps processing functions that transform GraphState - Supports sync lambdas and async Runnables - All node execution is async through dispatcher --- include/gopher/orch/graph/graph_node.h | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 include/gopher/orch/graph/graph_node.h diff --git a/include/gopher/orch/graph/graph_node.h b/include/gopher/orch/graph/graph_node.h new file mode 100644 index 00000000..5fb6f62d --- /dev/null +++ b/include/gopher/orch/graph/graph_node.h @@ -0,0 +1,54 @@ +#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 From 8a80bce352334166f15048eb1e2d5a00011ca33f Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 12:10:25 -0800 Subject: [PATCH 051/197] Add compiled_graph.h with CompiledStateGraph class (#13) Extracts CompiledStateGraph executor to its own header: - Implements Runnable interface - Pregel-style execution with iteration limits - START() and END() constants for graph boundaries - Clean error propagation and termination handling --- include/gopher/orch/graph/compiled_graph.h | 187 +++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 include/gopher/orch/graph/compiled_graph.h diff --git a/include/gopher/orch/graph/compiled_graph.h b/include/gopher/orch/graph/compiled_graph.h new file mode 100644 index 00000000..78341797 --- /dev/null +++ b/include/gopher/orch/graph/compiled_graph.h @@ -0,0 +1,187 @@ +#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 From f2b522a24f7685aa203d9618483d5ab823956a42 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 12:11:06 -0800 Subject: [PATCH 052/197] Refactor state_graph.h to use new graph headers (#13) Updates StateGraph builder to use extracted components: - Includes graph_state.h, graph_node.h, compiled_graph.h - Adds START() constant alongside END() - Adds addNodeAsync() for async lambda nodes - Removes duplicated GraphState and GraphNode definitions --- include/gopher/orch/graph/state_graph.h | 311 ++++++------------------ 1 file changed, 77 insertions(+), 234 deletions(-) diff --git a/include/gopher/orch/graph/state_graph.h b/include/gopher/orch/graph/state_graph.h index 920f196a..b16fd912 100644 --- a/include/gopher/orch/graph/state_graph.h +++ b/include/gopher/orch/graph/state_graph.h @@ -1,10 +1,21 @@ #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 @@ -12,6 +23,9 @@ #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 { @@ -19,129 +33,51 @@ namespace graph { using namespace gopher::orch::core; -// ============================================================================= -// GraphState - Container for all state channels -// ============================================================================= - -class GraphState { - public: - // Set a value by key - void set(const std::string& key, const JsonValue& value) { - 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 - uint64_t version(const std::string& key) const { - auto it = versions_.find(key); - return it != versions_.end() ? it->second : 0; - } - - // 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 - void merge(const GraphState& other) { - for (const auto& entry : other.channels_) { - channels_[entry.first] = entry.second; - versions_[entry.first]++; - } - } - - private: - std::map channels_; - std::map versions_; -}; - -// Callback type for graph node completion -using GraphStateCallback = std::function)>; - -// ============================================================================= -// 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_; -}; - -// Forward declaration -class CompiledStateGraph; - // ============================================================================= // 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 returns the next node name + // Condition function that evaluates state and returns next node name using EdgeCondition = std::function; - // Special node name for termination + // 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) { + 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) { @@ -150,7 +86,8 @@ class StateGraph { return; } - // Merge result into state + // Merge runnable output into state + // Output keys overwrite existing state keys GraphState new_state = state; const auto& output = mcp::get(result); if (output.isObject()) { @@ -166,13 +103,15 @@ class StateGraph { return *this; } - // Add a node with a sync lambda function + // 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); @@ -180,7 +119,7 @@ class StateGraph { } catch (const std::exception& e) { callback(makeOrchError( OrchError::INTERNAL_ERROR, - std::string("Node error: ") + e.what())); + std::string("Node execution error: ") + e.what())); } }); }; @@ -189,27 +128,52 @@ class StateGraph { return *this; } - // Add a direct edge (always transitions) + // 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) + // 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; } - // Set the entry point + // ------------------------------------------------------------------------- + // Graph Configuration + // ------------------------------------------------------------------------- + + // Set the entry point node (first node to execute) StateGraph& setEntryPoint(const std::string& node) { entry_point_ = node; return *this; } - // Compile into executable graph - std::shared_ptr compile(); + // ------------------------------------------------------------------------- + // 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_; @@ -220,127 +184,6 @@ class StateGraph { friend class CompiledStateGraph; }; -// ============================================================================= -// CompiledStateGraph - Executable state graph -// ============================================================================= - -class CompiledStateGraph : public Runnable { - public: - static constexpr size_t MAX_ITERATIONS = 100; - - explicit CompiledStateGraph(const StateGraph& graph) - : nodes_(graph.nodes_), - edges_(graph.edges_), - conditional_edges_(graph.conditional_edges_), - entry_point_(graph.entry_point_) {} - - std::string name() const override { return "CompiledStateGraph"; } - - void invoke(const JsonValue& input, - const RunnableConfig& config, - Dispatcher& dispatcher, - Callback callback) override { - if (entry_point_.empty()) { - dispatcher.post([callback = std::move(callback)]() { - callback(makeOrchError(OrchError::INVALID_ARGUMENT, - "StateGraph entry point not set")); - }); - return; - } - - // Initialize state from input - GraphState initial_state = GraphState::fromJson(input); - - // Start execution - executeNode(entry_point_, initial_state, config, dispatcher, 0, - std::move(callback)); - } - - private: - void executeNode(const std::string& node_name, - const GraphState& state, - const RunnableConfig& config, - Dispatcher& dispatcher, - size_t iteration, - Callback callback) { - // Check termination conditions - if (node_name.empty() || node_name == StateGraph::END()) { - dispatcher.post([state, callback = std::move(callback)]() { - callback(makeSuccess(state.toJson())); - }); - return; - } - - if (iteration >= MAX_ITERATIONS) { - dispatcher.post([callback = std::move(callback)]() { - callback(makeOrchError(OrchError::INTERNAL_ERROR, - "Maximum iterations exceeded")); - }); - return; - } - - // Find the node - auto it = nodes_.find(node_name); - if (it == nodes_.end()) { - dispatcher.post([node_name, callback = std::move(callback)]() { - callback(makeOrchError(OrchError::INVALID_ARGUMENT, - "Node not found: " + node_name)); - }); - return; - } - - // Execute the node - // Use static_pointer_cast since Runnable's shared_from_this returns the - // base type - 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; - } - - // Determine next node - const auto& new_state = mcp::get(result); - std::string next_node = self->getNextNode(node_name, new_state); - - // Continue execution - self->executeNode(next_node, new_state, config, dispatcher, - iteration + 1, std::move(callback)); - }); - } - - std::string getNextNode(const std::string& from, - const GraphState& state) const { - // Check conditional edges first - 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 StateGraph::END(); - } - - std::map> nodes_; - std::map edges_; - std::map conditional_edges_; - std::string entry_point_; -}; - -inline std::shared_ptr StateGraph::compile() { - return std::make_shared(*this); -} - } // namespace graph } // namespace orch } // namespace gopher From c22f7c1ac6834c09d5a3565b195028e6155c9628 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 12:11:28 -0800 Subject: [PATCH 053/197] Export new graph types in orch.h (#13) Adds exports for new graph module components: - StateChannel template class - ChannelConfig struct - reducers namespace alias for built-in reducers --- include/gopher/orch/orch.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index cc740c1f..9ffa9cfd 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -107,11 +107,14 @@ 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; From 734c1a178ac4a30cacba00623a4e58bb0b0cad65 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 12:12:11 -0800 Subject: [PATCH 054/197] Add StateChannel and reducer unit tests (#13) Adds comprehensive tests for new graph state features: - GraphStateWithReducerAppendArray: array concatenation - GraphStateWithReducerMergeObjects: object merging - GraphStateWithCustomReducer: custom max reducer - GraphStateMergeWithReducers: merge respects reducers - StateChannelTemplate: generic StateChannel operations - StateChannelWithReducer: sum reducer accumulation - GraphStateCopy: copy preserves reducer configuration - GraphStateKeys: key listing functionality - StateGraphSTARTConstant: START/END constants verification --- tests/gopher/orch/state_graph_test.cc | 182 ++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/tests/gopher/orch/state_graph_test.cc b/tests/gopher/orch/state_graph_test.cc index e324d820..c3b327ff 100644 --- a/tests/gopher/orch/state_graph_test.cc +++ b/tests/gopher/orch/state_graph_test.cc @@ -192,3 +192,185 @@ TEST_F(OrchTest, GraphStateOperations) { 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__"); +} From 206dd799bf39d53eee5228b9552fcba7e4f23f91 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 12:15:05 -0800 Subject: [PATCH 055/197] make format code to apply clang-format (#13) --- include/gopher/orch/graph/compiled_graph.h | 21 ++++++++++++--------- include/gopher/orch/graph/graph_node.h | 6 ++++-- include/gopher/orch/graph/graph_state.h | 3 ++- include/gopher/orch/graph/state_graph.h | 10 ++++------ tests/gopher/orch/state_graph_test.cc | 12 ++++++------ 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/include/gopher/orch/graph/compiled_graph.h b/include/gopher/orch/graph/compiled_graph.h index 78341797..546377b7 100644 --- a/include/gopher/orch/graph/compiled_graph.h +++ b/include/gopher/orch/graph/compiled_graph.h @@ -67,11 +67,10 @@ class CompiledStateGraph // 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) + 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)), @@ -79,8 +78,10 @@ class CompiledStateGraph std::string name() const override { return "CompiledStateGraph"; } - void invoke(const core::JsonValue& input, const core::RunnableConfig& config, - core::Dispatcher& dispatcher, Callback callback) override { + 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( @@ -101,9 +102,11 @@ class CompiledStateGraph 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, + void executeNode(const std::string& node_name, + const GraphState& state, const core::RunnableConfig& config, - core::Dispatcher& dispatcher, size_t iteration, + core::Dispatcher& dispatcher, + size_t iteration, Callback callback) { // Check termination conditions if (node_name.empty() || node_name == END()) { diff --git a/include/gopher/orch/graph/graph_node.h b/include/gopher/orch/graph/graph_node.h index 5fb6f62d..83851f9b 100644 --- a/include/gopher/orch/graph/graph_node.h +++ b/include/gopher/orch/graph/graph_node.h @@ -39,8 +39,10 @@ class GraphNode { const std::string& name() const { return name_; } - void invoke(const GraphState& state, const RunnableConfig& config, - Dispatcher& dispatcher, GraphStateCallback callback) { + void invoke(const GraphState& state, + const RunnableConfig& config, + Dispatcher& dispatcher, + GraphStateCallback callback) { func_(state, config, dispatcher, std::move(callback)); } diff --git a/include/gopher/orch/graph/graph_state.h b/include/gopher/orch/graph/graph_state.h index 2ba71920..edbfdecd 100644 --- a/include/gopher/orch/graph/graph_state.h +++ b/include/gopher/orch/graph/graph_state.h @@ -172,7 +172,8 @@ class GraphState { } // Configure a channel with default value - void configureChannel(const std::string& key, Reducer reducer, + void configureChannel(const std::string& key, + Reducer reducer, const JsonValue& default_value) { reducers_[key] = std::move(reducer); channels_[key] = default_value; diff --git a/include/gopher/orch/graph/state_graph.h b/include/gopher/orch/graph/state_graph.h index b16fd912..2f4d4e67 100644 --- a/include/gopher/orch/graph/state_graph.h +++ b/include/gopher/orch/graph/state_graph.h @@ -74,10 +74,9 @@ class StateGraph { // 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) { + 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) { @@ -130,8 +129,7 @@ class StateGraph { // 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) { + StateGraph& addNodeAsync(const std::string& name, GraphNode::NodeFunc func) { nodes_[name] = std::make_shared(name, std::move(func)); return *this; } diff --git a/tests/gopher/orch/state_graph_test.cc b/tests/gopher/orch/state_graph_test.cc index c3b327ff..be84ae3b 100644 --- a/tests/gopher/orch/state_graph_test.cc +++ b/tests/gopher/orch/state_graph_test.cc @@ -258,12 +258,12 @@ 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.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); From 26738ceee52aca269106ddc685642cc9a99d5f52 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:47:33 -0800 Subject: [PATCH 056/197] Add callback_handler.h with CallbackHandler interface (#17) Implements the observability callback system foundation: - EventType enum for event categorization (chain/tool/LLM/custom) - RunInfo struct for execution context (run ID, parent ID, timing, tags) - CallbackHandler base class with lifecycle event hooks - LoggingCallbackHandler for debug output - NoOpCallbackHandler for testing/disabling --- .../gopher/orch/callback/callback_handler.h | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 include/gopher/orch/callback/callback_handler.h diff --git a/include/gopher/orch/callback/callback_handler.h b/include/gopher/orch/callback/callback_handler.h new file mode 100644 index 00000000..eecfc470 --- /dev/null +++ b/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 From d75ff866410ff72ee4495a8b0c4bdcaadf9e6b55 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:48:20 -0800 Subject: [PATCH 057/197] Add callback_manager.h with CallbackManager class (#17) Implements callback event management: - Handler registration and removal - Event emission to all registered handlers - Child manager creation for hierarchical tracing - Tag and metadata inheritance for nested operations - ChainGuard RAII guard for automatic chain lifecycle - ToolGuard RAII guard for automatic tool lifecycle --- .../gopher/orch/callback/callback_manager.h | 483 ++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 include/gopher/orch/callback/callback_manager.h diff --git a/include/gopher/orch/callback/callback_manager.h b/include/gopher/orch/callback/callback_manager.h new file mode 100644 index 00000000..40b64ad2 --- /dev/null +++ b/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 From 6e0373794867d8ea333defea243f4b0f4dbee9ac Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:49:42 -0800 Subject: [PATCH 058/197] Add approval.h with HumanApproval and handlers (#17) Implements human-in-the-loop approval gates: - ApprovalRequest/ApprovalResponse structs for approval flow - ApprovalHandler abstract interface for approval mechanisms - HumanApproval runnable wrapper - CallbackApprovalHandler for sync callback-based approval - AsyncCallbackApprovalHandler for async approval - AutoApprovalHandler/AutoDenyHandler for testing - ConditionalApprovalHandler for rule-based approval - RecordingApprovalHandler for request recording --- include/gopher/orch/human/approval.h | 464 +++++++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 include/gopher/orch/human/approval.h diff --git a/include/gopher/orch/human/approval.h b/include/gopher/orch/human/approval.h new file mode 100644 index 00000000..48bfe102 --- /dev/null +++ b/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 From 768e1fcedd7d04de014b35ef83ea2a9270b959cb Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:50:14 -0800 Subject: [PATCH 059/197] Update RunnableConfig to include CallbackManager pointer (#17) Adds callback integration to RunnableConfig: - Forward declaration of CallbackManager to avoid circular deps - withCallbacks() builder method for setting callback manager - callbacks() accessor for retrieving callback manager - hasCallbacks() convenience method - Callback propagation through merge() and child() methods --- include/gopher/orch/core/config.h | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/include/gopher/orch/core/config.h b/include/gopher/orch/core/config.h index cabd1460..e5a794f5 100644 --- a/include/gopher/orch/core/config.h +++ b/include/gopher/orch/core/config.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -12,6 +13,12 @@ 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 @@ -51,6 +58,13 @@ class RunnableConfig { 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_; } @@ -72,6 +86,14 @@ class RunnableConfig { 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_) { @@ -92,6 +114,9 @@ class RunnableConfig { if (other.recursion_limit_ > 0) { recursion_limit_ = other.recursion_limit_; } + if (other.callbacks_) { + callbacks_ = other.callbacks_; + } return *this; } @@ -112,6 +137,7 @@ class RunnableConfig { 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 From fb8713adb15db65ec477a8cc98b98eb561c81afc Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:50:31 -0800 Subject: [PATCH 060/197] Export callback and human modules in orch.h (#17) Updates main header to include new modules: - Include callback_handler.h and callback_manager.h - Include human/approval.h - Re-export callback types (CallbackHandler, CallbackManager, etc.) - Re-export human types (ApprovalHandler, HumanApproval, etc.) --- include/gopher/orch/orch.h | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 9ffa9cfd..1ceea03d 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -39,6 +39,13 @@ // 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" + // Server abstraction #include "gopher/orch/server/mock_server.h" #include "gopher/orch/server/server.h" @@ -121,6 +128,29 @@ 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; From 39ec0da23e0967eaf889762ed39aedeee96ab631 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:51:56 -0800 Subject: [PATCH 061/197] Add callback_manager_test.cc unit tests (#17) Tests for callback system functionality: - CallbackHandler default methods - CallbackManager handler registration and removal - Chain and tool event emission - Custom event emission - Multiple handler support - Child manager creation and parent ID tracking - Tag and metadata inheritance - ChainGuard and ToolGuard RAII lifecycle - RunnableConfig callback integration --- tests/gopher/orch/callback_manager_test.cc | 499 +++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 tests/gopher/orch/callback_manager_test.cc diff --git a/tests/gopher/orch/callback_manager_test.cc b/tests/gopher/orch/callback_manager_test.cc new file mode 100644 index 00000000..c026cd09 --- /dev/null +++ b/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); +} From 68b2e5f9a3ab890cb0bde26f8722b68b98dc762b Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:52:26 -0800 Subject: [PATCH 062/197] Add human_approval_test.cc unit tests (#17) Tests for human-in-the-loop functionality: - ApprovalResponse factory methods - AutoApprovalHandler and AutoDenyHandler - CallbackApprovalHandler custom logic - ConditionalApprovalHandler rule-based approval - AsyncCallbackApprovalHandler async flow - RecordingApprovalHandler request recording - HumanApproval approved/denied/modified flows - Request preview content verification - Integration with CallbackManager --- tests/gopher/orch/human_approval_test.cc | 434 +++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 tests/gopher/orch/human_approval_test.cc diff --git a/tests/gopher/orch/human_approval_test.cc b/tests/gopher/orch/human_approval_test.cc new file mode 100644 index 00000000..04e06625 --- /dev/null +++ b/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"); +} From eb521184407a2e68d6880d0c306dafeec5605fe9 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 13:52:41 -0800 Subject: [PATCH 063/197] Update CMakeLists.txt to include new test files (#17) Adds callback and human approval tests to build: - callback_manager_test.cc - human_approval_test.cc --- tests/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 93341ae6..166d9159 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,6 +22,8 @@ set(ORCH_FRAMEWORK_TEST_SOURCES 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 From f875f5d816c8d515a111a4c7e7ed0e732a2c5be9 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:20:33 -0800 Subject: [PATCH 064/197] Add FFI types header with opaque handles and error codes (#16) Defines FFI-safe type definitions including: - Opaque handle types for all framework components - Error codes and structured error info - Callback function pointer typedefs - MCP/REST server configuration structures --- include/gopher/orch/ffi/orch_ffi_types.h | 567 +++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 include/gopher/orch/ffi/orch_ffi_types.h diff --git a/include/gopher/orch/ffi/orch_ffi_types.h b/include/gopher/orch/ffi/orch_ffi_types.h new file mode 100644 index 00000000..c6cf22c2 --- /dev/null +++ b/include/gopher/orch/ffi/orch_ffi_types.h @@ -0,0 +1,567 @@ +/** + * @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; + +/** 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_JSON = 4, + GOPHER_ORCH_TYPE_CONFIG = 5, + GOPHER_ORCH_TYPE_CALLBACK_MANAGER = 6, + GOPHER_ORCH_TYPE_APPROVAL_HANDLER = 7, + GOPHER_ORCH_TYPE_SEQUENCE = 8, + GOPHER_ORCH_TYPE_PARALLEL = 9, + GOPHER_ORCH_TYPE_ROUTER = 10, + GOPHER_ORCH_TYPE_FSM = 11, + GOPHER_ORCH_TYPE_GRAPH = 12, + GOPHER_ORCH_TYPE_COMPILED_GRAPH = 13, + GOPHER_ORCH_TYPE_CANCEL_TOKEN = 14, + GOPHER_ORCH_TYPE_ITERATOR = 15, + GOPHER_ORCH_TYPE_GUARD = 16, + GOPHER_ORCH_TYPE_TRANSACTION = 17, +} 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 */ From 22f19a16030e556fd94b966ee7009474dcfce3b7 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:20:50 -0800 Subject: [PATCH 065/197] Add FFI main C API header (#16) Comprehensive C API for cross-language bindings including: - Version and initialization functions - RAII guards and allocation transactions - Dispatcher, JSON, and Runnable APIs - Composition patterns (Sequence, Parallel, Router) - Resilience patterns (Retry, Timeout, Fallback, CircuitBreaker) - Server, FSM, and StateGraph APIs - Callback Manager and Approval Handler APIs --- include/gopher/orch/ffi/orch_ffi.h | 1339 ++++++++++++++++++++++++++++ 1 file changed, 1339 insertions(+) create mode 100644 include/gopher/orch/ffi/orch_ffi.h diff --git a/include/gopher/orch/ffi/orch_ffi.h b/include/gopher/orch/ffi/orch_ffi.h new file mode 100644 index 00000000..0726f670 --- /dev/null +++ b/include/gopher/orch/ffi/orch_ffi.h @@ -0,0 +1,1339 @@ +/** + * @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; + +/* ============================================================================ + * 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) + +#ifdef __cplusplus +} +#endif + +#endif /* GOPHER_ORCH_FFI_H */ From 17e7d44e97aed1a53fea3f5690a46dd70df583a4 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:21:02 -0800 Subject: [PATCH 066/197] Add FFI RAII utilities for C++ wrapper layer (#16) C++ RAII wrappers for safe use of C FFI API: - ResourceGuard for single resource management - AllocationTransaction for multi-resource operations - ScopedCleanup for scope-exit cleanup - ErrorScope for thread-local error handling - StringGuard for owned string management - Factory functions for common handle types --- include/gopher/orch/ffi/orch_ffi_raii.h | 553 ++++++++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 include/gopher/orch/ffi/orch_ffi_raii.h diff --git a/include/gopher/orch/ffi/orch_ffi_raii.h b/include/gopher/orch/ffi/orch_ffi_raii.h new file mode 100644 index 00000000..643dd861 --- /dev/null +++ b/include/gopher/orch/ffi/orch_ffi_raii.h @@ -0,0 +1,553 @@ +/** + * @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 */ From f2ae1ac4f683e35e0fe150fa9c133795e5cf6d59 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:21:18 -0800 Subject: [PATCH 067/197] Add FFI bridge implementation header (#16) Internal C++ to C bridge implementation: - HandleBase with reference counting and global registry - Handle implementations for all framework components - LambdaRunnable for C callback-based runnables - FFICallbackHandler and FFIApprovalHandler adapters - Thread-local ErrorManager for error propagation --- include/gopher/orch/ffi/orch_ffi_bridge.h | 821 ++++++++++++++++++++++ 1 file changed, 821 insertions(+) create mode 100644 include/gopher/orch/ffi/orch_ffi_bridge.h diff --git a/include/gopher/orch/ffi/orch_ffi_bridge.h b/include/gopher/orch/ffi/orch_ffi_bridge.h new file mode 100644 index 00000000..6dcad6bb --- /dev/null +++ b/include/gopher/orch/ffi/orch_ffi_bridge.h @@ -0,0 +1,821 @@ +/** + * @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/callback/callback_handler.h" +#include "gopher/orch/callback/callback_manager.h" +#include "gopher/orch/core/config.h" +#include "gopher/orch/core/dispatcher.h" +#include "gopher/orch/core/runnable.h" +#include "gopher/orch/core/types.h" +#include "gopher/orch/human/approval.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 + */ +struct DispatcherImpl : public HandleBase { + DispatcherImpl() + : HandleBase(GOPHER_ORCH_TYPE_DISPATCHER), + dispatcher(std::make_unique()) {} + + ~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; +}; + +/** + * 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 + */ +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; + object_iter_ = impl->value.begin(); + object_end_ = impl->value.end(); + } 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; + core::JsonValue::const_iterator object_iter_; + core::JsonValue::const_iterator object_end_; + 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 */ From 3fe686db2c2d3a062eb822f7b69a155ab2af98a9 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:21:29 -0800 Subject: [PATCH 068/197] Export FFI headers in orch.h (#16) Add FFI layer includes to main header: - Always include orch_ffi.h and orch_ffi_types.h - Conditionally include orch_ffi_raii.h when GOPHER_ORCH_WITH_FFI defined - Add ffi_utils namespace alias for RAII utilities --- include/gopher/orch/orch.h | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 1ceea03d..1fde4bbd 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -58,6 +58,15 @@ #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 { @@ -180,5 +189,11 @@ using server::RESTServerConfig; using server::RESTServerPtr; #endif +// 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 From 4d5c497b52df33ab1a31683ff6f8c92339f608b3 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:22:38 -0800 Subject: [PATCH 069/197] make format code to apply clang-format (#16) --- include/gopher/orch/ffi/orch_ffi.h | 468 +++++++++++----------- include/gopher/orch/ffi/orch_ffi_bridge.h | 241 ++++++----- include/gopher/orch/ffi/orch_ffi_raii.h | 11 +- include/gopher/orch/ffi/orch_ffi_types.h | 174 ++++---- 4 files changed, 458 insertions(+), 436 deletions(-) diff --git a/include/gopher/orch/ffi/orch_ffi.h b/include/gopher/orch/ffi/orch_ffi.h index 0726f670..7a0ea1f8 100644 --- a/include/gopher/orch/ffi/orch_ffi.h +++ b/include/gopher/orch/ffi/orch_ffi.h @@ -57,14 +57,15 @@ extern "C" { * Caller should verify version matches compiled headers */ GOPHER_ORCH_API void gopher_orch_version(int* major, - int* minor, - int* patch) GOPHER_ORCH_NOEXCEPT; + 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; +GOPHER_ORCH_API const char* gopher_orch_version_string(void) + GOPHER_ORCH_NOEXCEPT; /** * Initialize library (call once at startup) @@ -83,7 +84,8 @@ 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; +GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_is_initialized(void) + GOPHER_ORCH_NOEXCEPT; /* ============================================================================ * Error Handling @@ -126,8 +128,8 @@ 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; +GOPHER_ORCH_API void gopher_orch_string_buffer_free( + gopher_orch_string_buffer_t* buffer) GOPHER_ORCH_NOEXCEPT; /* ============================================================================ * RAII Guard Functions @@ -145,8 +147,7 @@ GOPHER_ORCH_API void gopher_orch_string_buffer_free(gopher_orch_string_buffer_t* * @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; + void* handle, gopher_orch_type_id_t type) GOPHER_ORCH_NOEXCEPT; /** * Create a RAII guard with custom cleanup function @@ -180,8 +181,8 @@ GOPHER_ORCH_API void gopher_orch_guard_destroy(gopher_orch_guard_t* guard) * @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; +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 @@ -221,10 +222,10 @@ GOPHER_ORCH_API gopher_orch_transaction_t gopher_orch_transaction_create_ex( * @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; +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) @@ -238,8 +239,8 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_transaction_commit( * 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; +GOPHER_ORCH_API void gopher_orch_transaction_rollback( + gopher_orch_transaction_t* txn) GOPHER_ORCH_NOEXCEPT; /** * Get number of resources in transaction @@ -267,15 +268,15 @@ GOPHER_ORCH_API gopher_orch_cancel_token_t gopher_orch_cancel_token_create(void) * 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; +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; +GOPHER_ORCH_API void gopher_orch_cancel_token_cancel( + gopher_orch_cancel_token_t token) GOPHER_ORCH_NOEXCEPT; /** * Check if cancelled @@ -312,8 +313,8 @@ GOPHER_ORCH_API gopher_orch_dispatcher_t gopher_orch_dispatcher_create_guarded( * Destroy dispatcher * @param dispatcher Dispatcher handle */ -GOPHER_ORCH_API void gopher_orch_dispatcher_destroy(gopher_orch_dispatcher_t dispatcher) - GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API void gopher_orch_dispatcher_destroy( + gopher_orch_dispatcher_t dispatcher) GOPHER_ORCH_NOEXCEPT; /** * Run dispatcher (blocks until stopped) @@ -337,16 +338,16 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_dispatcher_run_one( * @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; +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; +GOPHER_ORCH_API void gopher_orch_dispatcher_stop( + gopher_orch_dispatcher_t dispatcher) GOPHER_ORCH_NOEXCEPT; /** * Post work to dispatcher thread @@ -355,10 +356,10 @@ GOPHER_ORCH_API void gopher_orch_dispatcher_stop(gopher_orch_dispatcher_t dispat * @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; +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 @@ -377,85 +378,88 @@ GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_dispatcher_is_thread( */ /* 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_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_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; +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; +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_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_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_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; + 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_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_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; +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; +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 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 */ +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_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 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; @@ -472,8 +476,8 @@ GOPHER_ORCH_API gopher_orch_json_t gopher_orch_json_parse(const char* json_str) * @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; +GOPHER_ORCH_API gopher_orch_iterator_t +gopher_orch_json_iter(gopher_orch_json_t handle) GOPHER_ORCH_NOEXCEPT; /** * Destroy iterator @@ -487,8 +491,8 @@ GOPHER_ORCH_API void gopher_orch_iter_destroy(gopher_orch_iterator_t iter) * @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; +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) @@ -503,16 +507,16 @@ GOPHER_ORCH_API const char* gopher_orch_iter_key(gopher_orch_iterator_t iter) * @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; +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; +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) @@ -541,8 +545,8 @@ GOPHER_ORCH_API void gopher_orch_runnable_release(gopher_orch_runnable_t handle) * @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; +GOPHER_ORCH_API const char* gopher_orch_runnable_name( + gopher_orch_runnable_t handle) GOPHER_ORCH_NOEXCEPT; /** * Invoke runnable asynchronously @@ -592,10 +596,10 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_runnable_invoke_sync( * @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; +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 @@ -606,11 +610,12 @@ GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_lambda_create( * @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; +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 @@ -621,7 +626,8 @@ GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_lambda_create_with_destructor * Create default configuration * @return Config handle or NULL */ -GOPHER_ORCH_API gopher_orch_config_t gopher_orch_config_create(void) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_config_t gopher_orch_config_create(void) + GOPHER_ORCH_NOEXCEPT; /** * Destroy configuration @@ -647,8 +653,7 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_config_set_callbacks( * @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; + gopher_orch_config_t config, const char* tag) GOPHER_ORCH_NOEXCEPT; /** * Set metadata value @@ -657,10 +662,10 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_config_add_tag( * @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; +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 @@ -690,17 +695,17 @@ GOPHER_ORCH_API void gopher_orch_sequence_destroy(gopher_orch_sequence_t 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; +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; +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_sequence_build(gopher_orch_sequence_t handle) GOPHER_ORCH_NOEXCEPT; /* ============================================================================ * Composition API - Parallel @@ -730,18 +735,18 @@ GOPHER_ORCH_API void gopher_orch_parallel_destroy(gopher_orch_parallel_t handle) * @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; +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; +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_parallel_build(gopher_orch_parallel_t handle) GOPHER_ORCH_NOEXCEPT; /* ============================================================================ * Composition API - Router @@ -754,7 +759,8 @@ GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_parallel_build( * Create router builder * @return Router builder handle or NULL */ -GOPHER_ORCH_API gopher_orch_router_t gopher_orch_router_create(void) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_router_t gopher_orch_router_create(void) + GOPHER_ORCH_NOEXCEPT; /** * Destroy router builder @@ -771,11 +777,11 @@ GOPHER_ORCH_API void gopher_orch_router_destroy(gopher_orch_router_t handle) * @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; +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 @@ -792,8 +798,8 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_router_otherwise( * @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; +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_router_build(gopher_orch_router_t handle) GOPHER_ORCH_NOEXCEPT; /* ============================================================================ * Resilience API @@ -819,8 +825,7 @@ GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_retry_create( * @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; + gopher_orch_runnable_t inner, uint64_t timeout_ms) GOPHER_ORCH_NOEXCEPT; /** * Create fallback wrapper @@ -884,15 +889,14 @@ GOPHER_ORCH_API gopher_orch_bool_t gopher_orch_server_is_connected( /** * Get tool count */ -GOPHER_ORCH_API gopher_orch_size_t gopher_orch_server_tool_count( - gopher_orch_server_t handle) GOPHER_ORCH_NOEXCEPT; +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; + gopher_orch_server_t handle, gopher_orch_size_t index) GOPHER_ORCH_NOEXCEPT; /** * Get tool as runnable @@ -901,8 +905,7 @@ GOPHER_ORCH_API const char* gopher_orch_server_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; + gopher_orch_server_t handle, const char* tool_name) GOPHER_ORCH_NOEXCEPT; /** * Call tool directly (async) @@ -924,16 +927,16 @@ GOPHER_ORCH_API void gopher_orch_server_call_tool( /** * Create mock server */ -GOPHER_ORCH_API gopher_orch_server_t gopher_orch_mock_server_create(const char* name) - GOPHER_ORCH_NOEXCEPT; +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; +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 @@ -956,8 +959,7 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_mock_server_set_error( * 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; + gopher_orch_server_t handle, const char* tool_name) GOPHER_ORCH_NOEXCEPT; /* ============================================================================ * MCP Server API (real connections) @@ -967,10 +969,9 @@ GOPHER_ORCH_API gopher_orch_size_t gopher_orch_mock_server_call_count( /** * Server creation callback */ -typedef void (*gopher_orch_server_fn)( - void* user_context, - gopher_orch_error_t error, - gopher_orch_server_t server); +typedef void (*gopher_orch_server_fn)(void* user_context, + gopher_orch_error_t error, + gopher_orch_server_t server); /** * Create MCP server connection (async) @@ -984,10 +985,10 @@ GOPHER_ORCH_API void gopher_orch_mcp_server_create( /** * 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; +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) @@ -997,8 +998,8 @@ GOPHER_ORCH_API void gopher_orch_mcp_server_close( /** * Create callback manager */ -GOPHER_ORCH_API gopher_orch_callback_manager_t gopher_orch_callback_manager_create(void) - GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_callback_manager_t +gopher_orch_callback_manager_create(void) GOPHER_ORCH_NOEXCEPT; /** * Destroy callback manager @@ -1028,8 +1029,9 @@ GOPHER_ORCH_API void gopher_orch_callback_manager_clear( /** * 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; +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) @@ -1039,22 +1041,23 @@ GOPHER_ORCH_API gopher_orch_callback_manager_t gopher_orch_callback_manager_chil /** * 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; +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; +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; +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 @@ -1065,10 +1068,10 @@ GOPHER_ORCH_API void gopher_orch_approval_handler_destroy( /** * 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; +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) @@ -1090,57 +1093,57 @@ GOPHER_ORCH_API void gopher_orch_fsm_destroy(gopher_orch_fsm_t handle) /** * 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; +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; +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; +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; +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; +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; +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 @@ -1152,24 +1155,22 @@ GOPHER_ORCH_API int32_t gopher_orch_fsm_current_state(gopher_orch_fsm_t handle) * 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; + 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; +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); +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, @@ -1188,7 +1189,8 @@ GOPHER_ORCH_API void gopher_orch_fsm_trigger_async( /** * Create state graph builder */ -GOPHER_ORCH_API gopher_orch_graph_t gopher_orch_graph_create(void) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_graph_t gopher_orch_graph_create(void) + GOPHER_ORCH_NOEXCEPT; /** * Destroy state graph builder @@ -1207,26 +1209,25 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_graph_add_node( /** * 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; +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; +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; + gopher_orch_graph_t handle, const char* node_name) GOPHER_ORCH_NOEXCEPT; /** * Add state channel with reducer @@ -1239,8 +1240,8 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_graph_add_channel( /** * Compile graph into runnable */ -GOPHER_ORCH_API gopher_orch_runnable_t gopher_orch_graph_compile( - gopher_orch_graph_t handle) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_runnable_t +gopher_orch_graph_compile(gopher_orch_graph_t handle) GOPHER_ORCH_NOEXCEPT; /* ============================================================================ * Resource Statistics and Debugging @@ -1259,7 +1260,8 @@ GOPHER_ORCH_API gopher_orch_error_t gopher_orch_get_resource_stats( * Check for resource leaks * @return Number of leaked resources */ -GOPHER_ORCH_API gopher_orch_size_t gopher_orch_check_leaks(void) GOPHER_ORCH_NOEXCEPT; +GOPHER_ORCH_API gopher_orch_size_t gopher_orch_check_leaks(void) + GOPHER_ORCH_NOEXCEPT; /** * Print leak report to stderr @@ -1277,13 +1279,13 @@ GOPHER_ORCH_API void gopher_orch_print_leak_report(void) GOPHER_ORCH_NOEXCEPT; #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); \ - } \ +#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 */ @@ -1317,19 +1319,19 @@ GOPHER_ORCH_API void gopher_orch_print_leak_report(void) GOPHER_ORCH_NOEXCEPT; 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); \ - } \ +#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); \ - } \ +#define GOPHER_ORCH_SAFE_TXN_CLEANUP(txn_ptr) \ + do { \ + if (txn_ptr && *(txn_ptr)) { \ + gopher_orch_transaction_rollback(txn_ptr); \ + } \ } while (0) #ifdef __cplusplus diff --git a/include/gopher/orch/ffi/orch_ffi_bridge.h b/include/gopher/orch/ffi/orch_ffi_bridge.h index 6dcad6bb..237ff314 100644 --- a/include/gopher/orch/ffi/orch_ffi_bridge.h +++ b/include/gopher/orch/ffi/orch_ffi_bridge.h @@ -151,7 +151,8 @@ class HandleRegistry { void PrintLeakReport() const { std::lock_guard lock(mutex_); if (!handles_.empty()) { - fprintf(stderr, "gopher-orch FFI: %zu handles leaked:\n", handles_.size()); + 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), @@ -218,29 +219,52 @@ class ErrorManager { 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"; + 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"; } } @@ -424,7 +448,8 @@ struct RouterImpl : public HandleBase { * RAII guard implementation */ struct GuardImpl : public HandleBase { - GuardImpl(void* handle, gopher_orch_type_id_t type, + GuardImpl(void* handle, + gopher_orch_type_id_t type, gopher_orch_cleanup_fn cleanup) : HandleBase(GOPHER_ORCH_TYPE_GUARD), handle_(handle), @@ -539,11 +564,12 @@ struct TransactionImpl : public HandleBase { * ============================================================================ */ -class LambdaRunnable - : public core::Runnable { +class LambdaRunnable : public core::Runnable { public: - LambdaRunnable(gopher_orch_lambda_fn fn, void* user_context, - gopher_orch_destructor_fn destructor, std::string name) + 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), @@ -569,9 +595,9 @@ class LambdaRunnable /* 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); + auto result = + fn_(user_context_, reinterpret_cast(input_impl), + &error); /* Cleanup input handle */ input_impl->Release(); @@ -604,7 +630,8 @@ class LambdaRunnable class FFICallbackHandler : public callback::CallbackHandler { public: - explicit FFICallbackHandler(const gopher_orch_callback_handler_config_t& config) + explicit FFICallbackHandler( + const gopher_orch_callback_handler_config_t& config) : config_(config) {} ~FFICallbackHandler() override { @@ -617,9 +644,9 @@ class FFICallbackHandler : public callback::CallbackHandler { 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)); + config_.on_chain_start(config_.user_context, info.run_id.c_str(), + info.name.c_str(), + reinterpret_cast(input_impl)); input_impl->Release(); } } @@ -628,9 +655,9 @@ class FFICallbackHandler : public callback::CallbackHandler { 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)); + config_.on_chain_end(config_.user_context, info.run_id.c_str(), + info.name.c_str(), + reinterpret_cast(output_impl)); output_impl->Release(); } } @@ -638,52 +665,54 @@ class FFICallbackHandler : public callback::CallbackHandler { 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()); + 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, + 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)); + 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, + 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)); + 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, + 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()); + 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 { + 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); + config_.on_retry( + config_.user_context, info.run_id.c_str(), info.name.c_str(), + static_cast(error.code), attempt, max_attempts); } } @@ -691,9 +720,8 @@ class FFICallbackHandler : public callback::CallbackHandler { 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)); + config_.on_custom_event(config_.user_context, event_name.c_str(), + reinterpret_cast(data_impl)); data_impl->Release(); } } @@ -711,7 +739,8 @@ class FFICallbackHandler : public callback::CallbackHandler { class FFIApprovalHandler : public human::ApprovalHandler { public: - FFIApprovalHandler(gopher_orch_approval_fn fn, void* user_context, + FFIApprovalHandler(gopher_orch_approval_fn fn, + void* user_context, gopher_orch_destructor_fn destructor) : fn_(fn), user_context_(user_context), destructor_(destructor) {} @@ -766,52 +795,52 @@ class FFIApprovalHandler : public human::ApprovalHandler { * ============================================================================ */ -#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) { \ +#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; \ - } \ + 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) { \ +#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; \ - } \ + 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; \ +#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 diff --git a/include/gopher/orch/ffi/orch_ffi_raii.h b/include/gopher/orch/ffi/orch_ffi_raii.h index 643dd861..e598cbc1 100644 --- a/include/gopher/orch/ffi/orch_ffi_raii.h +++ b/include/gopher/orch/ffi/orch_ffi_raii.h @@ -241,7 +241,7 @@ class AllocationTransaction { /* Move support */ AllocationTransaction(AllocationTransaction&& other) noexcept : resources_(std::move(other.resources_)), committed_(other.committed_) { - other.committed_ = true; /* Prevent cleanup in moved-from object */ + other.committed_ = true; /* Prevent cleanup in moved-from object */ } AllocationTransaction& operator=(AllocationTransaction&& other) noexcept { @@ -306,7 +306,7 @@ class AllocationTransaction { } resources_.pop_back(); } - committed_ = true; /* Prevent double cleanup */ + committed_ = true; /* Prevent double cleanup */ } /** @@ -505,15 +505,16 @@ inline StringGuard stringify_json_pretty(gopher_orch_json_t json) { * Usage: * SyncCompletion completion; * gopher_orch_runnable_invoke(runnable, input, config, dispatcher, - * nullptr, SyncCompletion::callback, - * &completion); + * 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) {} + SyncCompletion() + : complete_(false), error_(GOPHER_ORCH_OK), result_(nullptr) {} /* Static callback for C API */ static void callback(void* user_context, diff --git a/include/gopher/orch/ffi/orch_ffi_types.h b/include/gopher/orch/ffi/orch_ffi_types.h index c6cf22c2..ef6b2b41 100644 --- a/include/gopher/orch/ffi/orch_ffi_types.h +++ b/include/gopher/orch/ffi/orch_ffi_types.h @@ -113,10 +113,12 @@ typedef struct gopher_orch_json_impl* gopher_orch_json_t; 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; +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; +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; @@ -234,11 +236,11 @@ typedef enum { */ 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_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; /* ============================================================================ @@ -253,15 +255,15 @@ typedef struct { /** 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) */ + 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 */ + 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; /* ============================================================================ @@ -299,10 +301,9 @@ typedef void (*gopher_orch_destructor_fn)(void* user_context); * @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); +typedef void (*gopher_orch_completion_fn)(void* user_context, + gopher_orch_error_t error, + gopher_orch_json_t result); /** * State transition observer callback @@ -312,11 +313,10 @@ typedef void (*gopher_orch_completion_fn)( * @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); +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 @@ -326,10 +326,9 @@ typedef void (*gopher_orch_transition_fn)( * @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); +typedef int32_t (*gopher_orch_guard_fn)(void* user_context, + int32_t from_state, + int32_t event); /** * State machine action callback @@ -339,11 +338,10 @@ typedef int32_t (*gopher_orch_guard_fn)( * @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); +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 @@ -352,9 +350,8 @@ typedef void (*gopher_orch_action_fn)( * @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); +typedef int32_t (*gopher_orch_condition_fn)(void* user_context, + gopher_orch_json_t input); /** * StateGraph conditional edge callback - returns destination node name @@ -364,9 +361,8 @@ typedef int32_t (*gopher_orch_condition_fn)( * @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); +typedef const char* (*gopher_orch_edge_condition_fn)(void* user_context, + gopher_orch_json_t state); /** * Lambda function for custom runnables @@ -393,16 +389,16 @@ typedef gopher_orch_json_t (*gopher_orch_lambda_fn)( * @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) + * @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); +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 @@ -412,59 +408,53 @@ typedef void (*gopher_orch_approval_fn)( * @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); +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); +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); +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); +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); +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); +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 @@ -480,11 +470,11 @@ typedef void (*gopher_orch_cleanup_fn)(void* resource); /** 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 */ + 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 */ @@ -503,15 +493,15 @@ typedef enum { /** MCP server configuration */ typedef struct { - const char* name; /* Server name */ + 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) */ + 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 */ + 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 */ @@ -536,7 +526,7 @@ typedef struct { 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_destructor_fn destructor; /* Called when handler is removed */ } gopher_orch_callback_handler_config_t; /** Transaction options */ @@ -548,9 +538,9 @@ typedef struct { /** 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) */ + 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 */ From 137ee141d0eee57f69d3a128770de53304c581e9 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:50:52 -0800 Subject: [PATCH 070/197] Fix FFI bridge header for compilation (#16) - Use LibeventDispatcher instead of abstract Dispatcher - Fix iterator types for JSON object iteration - Store keys in vector for proper iteration support --- include/gopher/orch/ffi/orch_ffi_bridge.h | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/include/gopher/orch/ffi/orch_ffi_bridge.h b/include/gopher/orch/ffi/orch_ffi_bridge.h index 237ff314..b30fe06f 100644 --- a/include/gopher/orch/ffi/orch_ffi_bridge.h +++ b/include/gopher/orch/ffi/orch_ffi_bridge.h @@ -44,11 +44,13 @@ #include "gopher/orch/callback/callback_handler.h" #include "gopher/orch/callback/callback_manager.h" #include "gopher/orch/core/config.h" -#include "gopher/orch/core/dispatcher.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 { @@ -309,11 +311,12 @@ struct JsonImpl : public HandleBase { /** * Dispatcher handle implementation + * Uses LibeventDispatcher as the concrete implementation */ struct DispatcherImpl : public HandleBase { DispatcherImpl() : HandleBase(GOPHER_ORCH_TYPE_DISPATCHER), - dispatcher(std::make_unique()) {} + dispatcher(std::make_unique("ffi")) {} ~DispatcherImpl() override { Cleanup(); } @@ -323,7 +326,7 @@ struct DispatcherImpl : public HandleBase { } } - std::unique_ptr dispatcher; + std::unique_ptr dispatcher; std::thread::id dispatcher_thread_id; }; @@ -381,6 +384,8 @@ struct CancelTokenImpl : public HandleBase { /** * 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) @@ -389,8 +394,8 @@ struct IteratorImpl : public HandleBase { auto* impl = reinterpret_cast(json); if (impl->value.isObject()) { is_object_ = true; - object_iter_ = impl->value.begin(); - object_end_ = impl->value.end(); + /* Store all keys for iteration */ + object_keys_ = impl->value.keys(); } else if (impl->value.isArray()) { is_object_ = false; array_size_ = impl->value.size(); @@ -401,8 +406,7 @@ struct IteratorImpl : public HandleBase { gopher_orch_json_t json_; size_t index_; bool is_object_ = false; - core::JsonValue::const_iterator object_iter_; - core::JsonValue::const_iterator object_end_; + std::vector object_keys_; size_t array_size_ = 0; std::string current_key_; core::JsonValue current_value_; From c406e4339179cd43b654ead63fb59e5efc17294d Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:51:49 -0800 Subject: [PATCH 071/197] Add comprehensive FFI unit tests (#16) Tests for FFI layer internal components: - Type definitions and constants - Error manager thread-local handling - Handle base and registry - Bridge handle implementations (JsonImpl, DispatcherImpl, etc.) - RAII utilities (ResourceGuard, AllocationTransaction, ScopedCleanup) - LambdaRunnable creation - Configuration structures 56 new tests covering FFI bridge internals. --- tests/gopher/orch/ffi_test.cc | 811 ++++++++++++++++++++++++++++++++++ 1 file changed, 811 insertions(+) create mode 100644 tests/gopher/orch/ffi_test.cc diff --git a/tests/gopher/orch/ffi_test.cc b/tests/gopher/orch/ffi_test.cc new file mode 100644 index 00000000..ba9904d3 --- /dev/null +++ b/tests/gopher/orch/ffi_test.cc @@ -0,0 +1,811 @@ +/** + * @file ffi_test.cc + * @brief Unit tests for FFI layer internal components + * + * Tests the FFI bridge internals including: + * - Type definitions and constants + * - Handle base and registry + * - Error manager + * - RAII utilities (ResourceGuard, AllocationTransaction, ScopedCleanup) + * - Bridge handle implementations + * + * Note: The C API functions (gopher_orch_*) require implementation. + * These tests focus on the internal C++ components that are header-only. + */ + +#include "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_raii.h" +#include "gopher/orch/ffi/orch_ffi_types.h" + +using namespace gopher::orch::ffi; + +// ============================================================================= +// Test Fixture for FFI Tests +// ============================================================================= + +class FFITest : public OrchTest { + protected: + void SetUp() override { + OrchTest::SetUp(); + ErrorManager::ClearError(); + } + + void TearDown() override { + ErrorManager::ClearError(); + OrchTest::TearDown(); + } +}; + +// ============================================================================= +// Type Definition Tests +// ============================================================================= + +TEST_F(FFITest, VersionMacros) { + EXPECT_GE(GOPHER_ORCH_VERSION_MAJOR, 1); + EXPECT_GE(GOPHER_ORCH_VERSION_MINOR, 0); + EXPECT_GE(GOPHER_ORCH_VERSION_PATCH, 0); +} + +TEST_F(FFITest, BooleanConstants) { + EXPECT_EQ(GOPHER_ORCH_FALSE, 0); + EXPECT_NE(GOPHER_ORCH_TRUE, 0); +} + +TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, TransportTypeValues) { + EXPECT_EQ(GOPHER_ORCH_TRANSPORT_STDIO, 0); + EXPECT_EQ(GOPHER_ORCH_TRANSPORT_SSE, 1); + EXPECT_EQ(GOPHER_ORCH_TRANSPORT_WEBSOCKET, 2); +} + +// ============================================================================= +// Error Manager Tests +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, ErrorManagerClear) { + ErrorManager::SetError(GOPHER_ORCH_ERROR_TIMEOUT, "Error"); + EXPECT_NE(ErrorManager::GetLastError(), nullptr); + + ErrorManager::ClearError(); + EXPECT_EQ(ErrorManager::GetLastError(), nullptr); +} + +TEST_F(FFITest, 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"); +} + +// ============================================================================= +// Handle Registry Tests +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, HandleRegistryInvalidHandle) { + EXPECT_FALSE(HandleRegistry::Instance().IsValid(nullptr)); + EXPECT_FALSE( + HandleRegistry::Instance().IsValid(reinterpret_cast(0x1234))); +} + +TEST_F(FFITest, 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(FFITest, 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 */ +} + +// ============================================================================= +// JsonImpl Tests +// ============================================================================= + +TEST_F(FFITest, JsonImplNull) { + auto* json = new JsonImpl(core::JsonValue::null()); + EXPECT_TRUE(json->value.isNull()); + json->Release(); +} + +TEST_F(FFITest, 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(FFITest, 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(); +} + +// ============================================================================= +// DispatcherImpl Tests +// ============================================================================= + +TEST_F(FFITest, DispatcherImplCreation) { + auto* dispatcher = new DispatcherImpl(); + EXPECT_NE(dispatcher->dispatcher, nullptr); + EXPECT_EQ(dispatcher->GetType(), GOPHER_ORCH_TYPE_DISPATCHER); + dispatcher->Release(); +} + +TEST_F(FFITest, 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(FFITest, ConfigImplCreation) { + auto* config = new ConfigImpl(); + EXPECT_EQ(config->GetType(), GOPHER_ORCH_TYPE_CONFIG); + config->Release(); +} + +TEST_F(FFITest, 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(FFITest, CancelTokenImplCreation) { + auto* token = new CancelTokenImpl(); + EXPECT_EQ(token->GetType(), GOPHER_ORCH_TYPE_CANCEL_TOKEN); + EXPECT_FALSE(token->cancelled.load()); + token->Release(); +} + +TEST_F(FFITest, CancelTokenImplCancel) { + auto* token = new CancelTokenImpl(); + EXPECT_FALSE(token->cancelled.load()); + + token->cancelled.store(true); + EXPECT_TRUE(token->cancelled.load()); + + token->Release(); +} + +// ============================================================================= +// SequenceImpl Tests +// ============================================================================= + +TEST_F(FFITest, SequenceImplCreation) { + auto* seq = new SequenceImpl(); + EXPECT_EQ(seq->GetType(), GOPHER_ORCH_TYPE_SEQUENCE); + EXPECT_TRUE(seq->steps.empty()); + seq->Release(); +} + +// ============================================================================= +// ParallelImpl Tests +// ============================================================================= + +TEST_F(FFITest, ParallelImplCreation) { + auto* parallel = new ParallelImpl(); + EXPECT_EQ(parallel->GetType(), GOPHER_ORCH_TYPE_PARALLEL); + EXPECT_TRUE(parallel->branches.empty()); + parallel->Release(); +} + +// ============================================================================= +// RouterImpl Tests +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, TransactionImplCreation) { + auto* txn = new TransactionImpl(nullptr); + EXPECT_EQ(txn->GetType(), GOPHER_ORCH_TYPE_TRANSACTION); + EXPECT_EQ(txn->Size(), 0); + txn->Release(); +} + +TEST_F(FFITest, 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(FFITest, 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(); +} + +// ============================================================================= +// GuardImpl Tests +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, 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(FFITest, 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); +} + +// ============================================================================= +// RAII Utility Tests - ResourceGuard +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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)); +} + +// ============================================================================= +// RAII Utility Tests - AllocationTransaction +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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 */ +} + +// ============================================================================= +// RAII Utility Tests - ScopedCleanup +// ============================================================================= + +TEST_F(FFITest, ScopedCleanupBasic) { + static bool cleaned = false; + cleaned = false; + + { ScopedCleanup cleanup([&]() { cleaned = true; }); } + + EXPECT_TRUE(cleaned); +} + +TEST_F(FFITest, ScopedCleanupDismiss) { + static bool cleaned = false; + cleaned = false; + + { + ScopedCleanup cleanup([&]() { cleaned = true; }); + cleanup.dismiss(); + } + + EXPECT_FALSE(cleaned); +} + +TEST_F(FFITest, 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(FFITest, 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); +} + +// ============================================================================= +// Error Scope Pattern Tests (using ErrorManager directly) +// ============================================================================= + +TEST_F(FFITest, 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"); + } +} + +// ============================================================================= +// LambdaRunnable Tests +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, 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(FFITest, 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); +} + +// ============================================================================= +// Configuration Structure Tests +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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); +} + +// ============================================================================= +// CallbackManager Handle Tests +// ============================================================================= + +TEST_F(FFITest, CallbackManagerImplCreation) { + auto* manager = new CallbackManagerImpl(); + EXPECT_EQ(manager->GetType(), GOPHER_ORCH_TYPE_CALLBACK_MANAGER); + EXPECT_NE(manager->manager, nullptr); + manager->Release(); +} + +// ============================================================================= +// ApprovalHandler Handle Tests +// ============================================================================= + +TEST_F(FFITest, 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(); +} + +// ============================================================================= +// Iterator Handle Tests +// ============================================================================= + +TEST_F(FFITest, 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(FFITest, 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(); +} From 24614862c3078e75755401b485c469949e81024d Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 15:52:19 -0800 Subject: [PATCH 072/197] Update CMakeLists.txt to include FFI tests (#16) Add ffi_test.cc to ORCH_FRAMEWORK_TEST_SOURCES. --- tests/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 166d9159..c04192c9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,6 +29,7 @@ set(ORCH_FRAMEWORK_TEST_SOURCES gopher/orch/mcp_server_test.cc gopher/orch/rest_server_test.cc gopher/orch/integration_test.cc + gopher/orch/ffi_test.cc ) # Helper function to create orch test executables From d694dcfc79209023907e7d0b21d537188509b330 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:37:53 -0800 Subject: [PATCH 073/197] Add ffi_types_test.cc for type definitions and config structures (#16) Split from monolithic ffi_test.cc for better organization. Tests version macros, boolean constants, error codes, type IDs, channel types, transport types, and configuration structures. --- tests/gopher/orch/FFI/ffi_types_test.cc | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_types_test.cc diff --git a/tests/gopher/orch/FFI/ffi_types_test.cc b/tests/gopher/orch/FFI/ffi_types_test.cc new file mode 100644 index 00000000..36c82e57 --- /dev/null +++ b/tests/gopher/orch/FFI/ffi_types_test.cc @@ -0,0 +1,126 @@ +/** + * @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 "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.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); +} From 92a59413d2c6ba262704b79511746cbf6ef56cd9 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:38:04 -0800 Subject: [PATCH 074/197] Add ffi_error_test.cc for error handling tests (#16) Split from monolithic ffi_test.cc for better organization. Tests ErrorManager set/get, clear, get name, and error scope pattern. --- tests/gopher/orch/FFI/ffi_error_test.cc | 96 +++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_error_test.cc diff --git a/tests/gopher/orch/FFI/ffi_error_test.cc b/tests/gopher/orch/FFI/ffi_error_test.cc new file mode 100644 index 00000000..ad9b5399 --- /dev/null +++ b/tests/gopher/orch/FFI/ffi_error_test.cc @@ -0,0 +1,96 @@ +/** + * @file ffi_error_test.cc + * @brief Unit tests for FFI error handling + * + * Tests: + * - ErrorManager SetAndGet + * - ErrorManager Clear + * - ErrorManager GetName + * - Error scope pattern + */ + +#include "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.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"); + } +} From 408a5750e5b1a290d6dba670d569612dda4a8676 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:38:13 -0800 Subject: [PATCH 075/197] Add ffi_handle_test.cc for handle management tests (#16) Split from monolithic ffi_test.cc for better organization. Tests HandleRegistry, HandleBase ref counting, and GuardImpl. --- tests/gopher/orch/FFI/ffi_handle_test.cc | 159 +++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_handle_test.cc diff --git a/tests/gopher/orch/FFI/ffi_handle_test.cc b/tests/gopher/orch/FFI/ffi_handle_test.cc new file mode 100644 index 00000000..96db012c --- /dev/null +++ b/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 "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.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); +} From 900215dd7e0077232fad5dbcbc96a8889abac1ac Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:38:22 -0800 Subject: [PATCH 076/197] Add ffi_json_test.cc for JSON handling tests (#16) Split from monolithic ffi_test.cc for better organization. Tests JsonImpl null/object/array and IteratorImpl for object/array. --- tests/gopher/orch/FFI/ffi_json_test.cc | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_json_test.cc diff --git a/tests/gopher/orch/FFI/ffi_json_test.cc b/tests/gopher/orch/FFI/ffi_json_test.cc new file mode 100644 index 00000000..cccb5a7a --- /dev/null +++ b/tests/gopher/orch/FFI/ffi_json_test.cc @@ -0,0 +1,91 @@ +/** + * @file ffi_json_test.cc + * @brief Unit tests for FFI JSON handling + * + * Tests: + * - JsonImpl (Null, Object, Array) + * - IteratorImpl (ObjectIteration, ArrayIteration) + */ + +#include "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.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(); +} From 36071c984a80c0b186f9f8edf9056dc13789c745 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:38:30 -0800 Subject: [PATCH 077/197] Add ffi_core_test.cc for core component tests (#16) Split from monolithic ffi_test.cc for better organization. Tests DispatcherImpl, ConfigImpl, and CancelTokenImpl. --- tests/gopher/orch/FFI/ffi_core_test.cc | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_core_test.cc diff --git a/tests/gopher/orch/FFI/ffi_core_test.cc b/tests/gopher/orch/FFI/ffi_core_test.cc new file mode 100644 index 00000000..5c84982c --- /dev/null +++ b/tests/gopher/orch/FFI/ffi_core_test.cc @@ -0,0 +1,94 @@ +/** + * @file ffi_core_test.cc + * @brief Unit tests for FFI core components + * + * Tests: + * - DispatcherImpl (Creation, Post) + * - ConfigImpl (Creation, WithTag) + * - CancelTokenImpl (Creation, Cancel) + */ + +#include "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.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(); +} From 4376696df1e5fe95d4062974c497cb94932aafa8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:38:37 -0800 Subject: [PATCH 078/197] Add ffi_builder_test.cc for builder component tests (#16) Split from monolithic ffi_test.cc for better organization. Tests SequenceImpl, ParallelImpl, RouterImpl, and TransactionImpl. --- tests/gopher/orch/FFI/ffi_builder_test.cc | 112 ++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_builder_test.cc diff --git a/tests/gopher/orch/FFI/ffi_builder_test.cc b/tests/gopher/orch/FFI/ffi_builder_test.cc new file mode 100644 index 00000000..00afe543 --- /dev/null +++ b/tests/gopher/orch/FFI/ffi_builder_test.cc @@ -0,0 +1,112 @@ +/** + * @file ffi_builder_test.cc + * @brief Unit tests for FFI builder components + * + * Tests: + * - SequenceImpl (Creation) + * - ParallelImpl (Creation) + * - RouterImpl (Creation) + * - TransactionImpl (Creation, AddAndCommit, Rollback) + */ + +#include "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.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(); +} From 92525ffc9d4b766b65549229f2476f74205029cb Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:38:47 -0800 Subject: [PATCH 079/197] Add ffi_raii_test.cc for RAII utility tests (#16) Split from monolithic ffi_test.cc for better organization. Tests ResourceGuard, AllocationTransaction, and ScopedCleanup. --- tests/gopher/orch/FFI/ffi_raii_test.cc | 235 +++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_raii_test.cc diff --git a/tests/gopher/orch/FFI/ffi_raii_test.cc b/tests/gopher/orch/FFI/ffi_raii_test.cc new file mode 100644 index 00000000..7eb703dd --- /dev/null +++ b/tests/gopher/orch/FFI/ffi_raii_test.cc @@ -0,0 +1,235 @@ +/** + * @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 "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_raii.h" +#include "gopher/orch/ffi/orch_ffi_types.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); +} From 8e8b87b524ea581269e7b1342799451c3dcde7d3 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:38:56 -0800 Subject: [PATCH 080/197] Add ffi_lambda_test.cc for lambda and callback tests (#16) Split from monolithic ffi_test.cc for better organization. Tests LambdaRunnable, CallbackManagerImpl, and ApprovalHandlerImpl. --- tests/gopher/orch/FFI/ffi_lambda_test.cc | 113 +++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/gopher/orch/FFI/ffi_lambda_test.cc diff --git a/tests/gopher/orch/FFI/ffi_lambda_test.cc b/tests/gopher/orch/FFI/ffi_lambda_test.cc new file mode 100644 index 00000000..80facf3f --- /dev/null +++ b/tests/gopher/orch/FFI/ffi_lambda_test.cc @@ -0,0 +1,113 @@ +/** + * @file ffi_lambda_test.cc + * @brief Unit tests for FFI lambda and callback components + * + * Tests: + * - LambdaRunnable (Creation, WithContext, Destructor) + * - CallbackManagerImpl (Creation) + * - ApprovalHandlerImpl (Creation) + */ + +#include "orch_test_fixture.h" + +#include "gopher/orch/ffi/orch_ffi_bridge.h" +#include "gopher/orch/ffi/orch_ffi_types.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(); +} From 48d9cc0f63e5d26e57b46f476e19359201070ede Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:39:10 -0800 Subject: [PATCH 081/197] Update CMakeLists.txt for split FFI test files (#16) Reorganize FFI tests into separate files under gopher/orch/FFI/. Add FFI_TEST_SOURCES variable and ffi_test target. Update include directories to include FFI folder. --- tests/CMakeLists.txt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c04192c9..21cccd04 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,7 +29,18 @@ set(ORCH_FRAMEWORK_TEST_SOURCES gopher/orch/mcp_server_test.cc gopher/orch/rest_server_test.cc gopher/orch/integration_test.cc - gopher/orch/ffi_test.cc +) + +# 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 @@ -53,6 +64,7 @@ function(add_orch_test test_name test_sources) ${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} ) @@ -69,10 +81,14 @@ 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 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} + ${FFI_TEST_SOURCES} ${TEST_UTIL_SOURCES} ) @@ -96,6 +112,7 @@ 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} ) From 8d66529d66cd334662b1423f475747c82e356e5a Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:39:20 -0800 Subject: [PATCH 082/197] Remove monolithic ffi_test.cc (#16) Replaced by split test files in gopher/orch/FFI/ directory for better organization and easier navigation. --- tests/gopher/orch/ffi_test.cc | 811 ---------------------------------- 1 file changed, 811 deletions(-) delete mode 100644 tests/gopher/orch/ffi_test.cc diff --git a/tests/gopher/orch/ffi_test.cc b/tests/gopher/orch/ffi_test.cc deleted file mode 100644 index ba9904d3..00000000 --- a/tests/gopher/orch/ffi_test.cc +++ /dev/null @@ -1,811 +0,0 @@ -/** - * @file ffi_test.cc - * @brief Unit tests for FFI layer internal components - * - * Tests the FFI bridge internals including: - * - Type definitions and constants - * - Handle base and registry - * - Error manager - * - RAII utilities (ResourceGuard, AllocationTransaction, ScopedCleanup) - * - Bridge handle implementations - * - * Note: The C API functions (gopher_orch_*) require implementation. - * These tests focus on the internal C++ components that are header-only. - */ - -#include "orch_test_fixture.h" - -#include "gopher/orch/ffi/orch_ffi_bridge.h" -#include "gopher/orch/ffi/orch_ffi_raii.h" -#include "gopher/orch/ffi/orch_ffi_types.h" - -using namespace gopher::orch::ffi; - -// ============================================================================= -// Test Fixture for FFI Tests -// ============================================================================= - -class FFITest : public OrchTest { - protected: - void SetUp() override { - OrchTest::SetUp(); - ErrorManager::ClearError(); - } - - void TearDown() override { - ErrorManager::ClearError(); - OrchTest::TearDown(); - } -}; - -// ============================================================================= -// Type Definition Tests -// ============================================================================= - -TEST_F(FFITest, VersionMacros) { - EXPECT_GE(GOPHER_ORCH_VERSION_MAJOR, 1); - EXPECT_GE(GOPHER_ORCH_VERSION_MINOR, 0); - EXPECT_GE(GOPHER_ORCH_VERSION_PATCH, 0); -} - -TEST_F(FFITest, BooleanConstants) { - EXPECT_EQ(GOPHER_ORCH_FALSE, 0); - EXPECT_NE(GOPHER_ORCH_TRUE, 0); -} - -TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, TransportTypeValues) { - EXPECT_EQ(GOPHER_ORCH_TRANSPORT_STDIO, 0); - EXPECT_EQ(GOPHER_ORCH_TRANSPORT_SSE, 1); - EXPECT_EQ(GOPHER_ORCH_TRANSPORT_WEBSOCKET, 2); -} - -// ============================================================================= -// Error Manager Tests -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, ErrorManagerClear) { - ErrorManager::SetError(GOPHER_ORCH_ERROR_TIMEOUT, "Error"); - EXPECT_NE(ErrorManager::GetLastError(), nullptr); - - ErrorManager::ClearError(); - EXPECT_EQ(ErrorManager::GetLastError(), nullptr); -} - -TEST_F(FFITest, 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"); -} - -// ============================================================================= -// Handle Registry Tests -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, HandleRegistryInvalidHandle) { - EXPECT_FALSE(HandleRegistry::Instance().IsValid(nullptr)); - EXPECT_FALSE( - HandleRegistry::Instance().IsValid(reinterpret_cast(0x1234))); -} - -TEST_F(FFITest, 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(FFITest, 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 */ -} - -// ============================================================================= -// JsonImpl Tests -// ============================================================================= - -TEST_F(FFITest, JsonImplNull) { - auto* json = new JsonImpl(core::JsonValue::null()); - EXPECT_TRUE(json->value.isNull()); - json->Release(); -} - -TEST_F(FFITest, 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(FFITest, 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(); -} - -// ============================================================================= -// DispatcherImpl Tests -// ============================================================================= - -TEST_F(FFITest, DispatcherImplCreation) { - auto* dispatcher = new DispatcherImpl(); - EXPECT_NE(dispatcher->dispatcher, nullptr); - EXPECT_EQ(dispatcher->GetType(), GOPHER_ORCH_TYPE_DISPATCHER); - dispatcher->Release(); -} - -TEST_F(FFITest, 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(FFITest, ConfigImplCreation) { - auto* config = new ConfigImpl(); - EXPECT_EQ(config->GetType(), GOPHER_ORCH_TYPE_CONFIG); - config->Release(); -} - -TEST_F(FFITest, 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(FFITest, CancelTokenImplCreation) { - auto* token = new CancelTokenImpl(); - EXPECT_EQ(token->GetType(), GOPHER_ORCH_TYPE_CANCEL_TOKEN); - EXPECT_FALSE(token->cancelled.load()); - token->Release(); -} - -TEST_F(FFITest, CancelTokenImplCancel) { - auto* token = new CancelTokenImpl(); - EXPECT_FALSE(token->cancelled.load()); - - token->cancelled.store(true); - EXPECT_TRUE(token->cancelled.load()); - - token->Release(); -} - -// ============================================================================= -// SequenceImpl Tests -// ============================================================================= - -TEST_F(FFITest, SequenceImplCreation) { - auto* seq = new SequenceImpl(); - EXPECT_EQ(seq->GetType(), GOPHER_ORCH_TYPE_SEQUENCE); - EXPECT_TRUE(seq->steps.empty()); - seq->Release(); -} - -// ============================================================================= -// ParallelImpl Tests -// ============================================================================= - -TEST_F(FFITest, ParallelImplCreation) { - auto* parallel = new ParallelImpl(); - EXPECT_EQ(parallel->GetType(), GOPHER_ORCH_TYPE_PARALLEL); - EXPECT_TRUE(parallel->branches.empty()); - parallel->Release(); -} - -// ============================================================================= -// RouterImpl Tests -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, TransactionImplCreation) { - auto* txn = new TransactionImpl(nullptr); - EXPECT_EQ(txn->GetType(), GOPHER_ORCH_TYPE_TRANSACTION); - EXPECT_EQ(txn->Size(), 0); - txn->Release(); -} - -TEST_F(FFITest, 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(FFITest, 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(); -} - -// ============================================================================= -// GuardImpl Tests -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, 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(FFITest, 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); -} - -// ============================================================================= -// RAII Utility Tests - ResourceGuard -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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)); -} - -// ============================================================================= -// RAII Utility Tests - AllocationTransaction -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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 */ -} - -// ============================================================================= -// RAII Utility Tests - ScopedCleanup -// ============================================================================= - -TEST_F(FFITest, ScopedCleanupBasic) { - static bool cleaned = false; - cleaned = false; - - { ScopedCleanup cleanup([&]() { cleaned = true; }); } - - EXPECT_TRUE(cleaned); -} - -TEST_F(FFITest, ScopedCleanupDismiss) { - static bool cleaned = false; - cleaned = false; - - { - ScopedCleanup cleanup([&]() { cleaned = true; }); - cleanup.dismiss(); - } - - EXPECT_FALSE(cleaned); -} - -TEST_F(FFITest, 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(FFITest, 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); -} - -// ============================================================================= -// Error Scope Pattern Tests (using ErrorManager directly) -// ============================================================================= - -TEST_F(FFITest, 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"); - } -} - -// ============================================================================= -// LambdaRunnable Tests -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, 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(FFITest, 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); -} - -// ============================================================================= -// Configuration Structure Tests -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, 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(FFITest, 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(FFITest, 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); -} - -// ============================================================================= -// CallbackManager Handle Tests -// ============================================================================= - -TEST_F(FFITest, CallbackManagerImplCreation) { - auto* manager = new CallbackManagerImpl(); - EXPECT_EQ(manager->GetType(), GOPHER_ORCH_TYPE_CALLBACK_MANAGER); - EXPECT_NE(manager->manager, nullptr); - manager->Release(); -} - -// ============================================================================= -// ApprovalHandler Handle Tests -// ============================================================================= - -TEST_F(FFITest, 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(); -} - -// ============================================================================= -// Iterator Handle Tests -// ============================================================================= - -TEST_F(FFITest, 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(FFITest, 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(); -} From 1473b6848492232aa6d8726f4b1a88666ade7939 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 28 Dec 2025 16:40:43 -0800 Subject: [PATCH 083/197] make format code to apply clang-format (#16) --- tests/gopher/orch/FFI/ffi_builder_test.cc | 3 +-- tests/gopher/orch/FFI/ffi_core_test.cc | 3 +-- tests/gopher/orch/FFI/ffi_error_test.cc | 3 +-- tests/gopher/orch/FFI/ffi_handle_test.cc | 8 ++++---- tests/gopher/orch/FFI/ffi_json_test.cc | 3 +-- tests/gopher/orch/FFI/ffi_lambda_test.cc | 3 +-- tests/gopher/orch/FFI/ffi_raii_test.cc | 7 ++++--- tests/gopher/orch/FFI/ffi_types_test.cc | 3 +-- 8 files changed, 14 insertions(+), 19 deletions(-) diff --git a/tests/gopher/orch/FFI/ffi_builder_test.cc b/tests/gopher/orch/FFI/ffi_builder_test.cc index 00afe543..2ce134af 100644 --- a/tests/gopher/orch/FFI/ffi_builder_test.cc +++ b/tests/gopher/orch/FFI/ffi_builder_test.cc @@ -9,10 +9,9 @@ * - TransactionImpl (Creation, AddAndCommit, Rollback) */ -#include "orch_test_fixture.h" - #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; diff --git a/tests/gopher/orch/FFI/ffi_core_test.cc b/tests/gopher/orch/FFI/ffi_core_test.cc index 5c84982c..ad738d6e 100644 --- a/tests/gopher/orch/FFI/ffi_core_test.cc +++ b/tests/gopher/orch/FFI/ffi_core_test.cc @@ -8,10 +8,9 @@ * - CancelTokenImpl (Creation, Cancel) */ -#include "orch_test_fixture.h" - #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; diff --git a/tests/gopher/orch/FFI/ffi_error_test.cc b/tests/gopher/orch/FFI/ffi_error_test.cc index ad9b5399..63e6efb2 100644 --- a/tests/gopher/orch/FFI/ffi_error_test.cc +++ b/tests/gopher/orch/FFI/ffi_error_test.cc @@ -9,10 +9,9 @@ * - Error scope pattern */ -#include "orch_test_fixture.h" - #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; diff --git a/tests/gopher/orch/FFI/ffi_handle_test.cc b/tests/gopher/orch/FFI/ffi_handle_test.cc index 96db012c..442de2ff 100644 --- a/tests/gopher/orch/FFI/ffi_handle_test.cc +++ b/tests/gopher/orch/FFI/ffi_handle_test.cc @@ -8,10 +8,9 @@ * - GuardImpl (Creation, WithCleanup, Release) */ -#include "orch_test_fixture.h" - #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; @@ -146,8 +145,9 @@ static void guard_release_cleanup_fn(void*) { 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); + 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)); diff --git a/tests/gopher/orch/FFI/ffi_json_test.cc b/tests/gopher/orch/FFI/ffi_json_test.cc index cccb5a7a..b619e0f9 100644 --- a/tests/gopher/orch/FFI/ffi_json_test.cc +++ b/tests/gopher/orch/FFI/ffi_json_test.cc @@ -7,10 +7,9 @@ * - IteratorImpl (ObjectIteration, ArrayIteration) */ -#include "orch_test_fixture.h" - #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; diff --git a/tests/gopher/orch/FFI/ffi_lambda_test.cc b/tests/gopher/orch/FFI/ffi_lambda_test.cc index 80facf3f..4ac86d8f 100644 --- a/tests/gopher/orch/FFI/ffi_lambda_test.cc +++ b/tests/gopher/orch/FFI/ffi_lambda_test.cc @@ -8,10 +8,9 @@ * - ApprovalHandlerImpl (Creation) */ -#include "orch_test_fixture.h" - #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; diff --git a/tests/gopher/orch/FFI/ffi_raii_test.cc b/tests/gopher/orch/FFI/ffi_raii_test.cc index 7eb703dd..fadcaf32 100644 --- a/tests/gopher/orch/FFI/ffi_raii_test.cc +++ b/tests/gopher/orch/FFI/ffi_raii_test.cc @@ -8,11 +8,10 @@ * - ScopedCleanup (Basic, Dismiss, Execute, Move) */ -#include "orch_test_fixture.h" - #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; @@ -189,7 +188,9 @@ TEST_F(FFIRaiiTest, ScopedCleanupBasic) { static bool cleaned = false; cleaned = false; - { ScopedCleanup cleanup([&]() { cleaned = true; }); } + { + ScopedCleanup cleanup([&]() { cleaned = true; }); + } EXPECT_TRUE(cleaned); } diff --git a/tests/gopher/orch/FFI/ffi_types_test.cc b/tests/gopher/orch/FFI/ffi_types_test.cc index 36c82e57..ec87cfc0 100644 --- a/tests/gopher/orch/FFI/ffi_types_test.cc +++ b/tests/gopher/orch/FFI/ffi_types_test.cc @@ -12,10 +12,9 @@ * - Configuration structures (RetryPolicy, CircuitBreaker, McpConfig, etc.) */ -#include "orch_test_fixture.h" - #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; From db251cd23360344dde6b2d97ca7946291c933a1e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Mon, 29 Dec 2025 10:29:36 -0800 Subject: [PATCH 084/197] Fix extern C block to exclude C++ RAII helpers (#20) Close the extern "C" block before the C++ RAII helper macros section to avoid including and inside extern "C" linkage, which causes template compilation errors. --- include/gopher/orch/ffi/orch_ffi.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/include/gopher/orch/ffi/orch_ffi.h b/include/gopher/orch/ffi/orch_ffi.h index 7a0ea1f8..d2d02412 100644 --- a/include/gopher/orch/ffi/orch_ffi.h +++ b/include/gopher/orch/ffi/orch_ffi.h @@ -1268,6 +1268,10 @@ GOPHER_ORCH_API gopher_orch_size_t gopher_orch_check_leaks(void) */ GOPHER_ORCH_API void gopher_orch_print_leak_report(void) GOPHER_ORCH_NOEXCEPT; +#ifdef __cplusplus +} /* extern "C" */ +#endif + /* ============================================================================ * RAII Helper Macros (for C++ users of the C API) * ============================================================================ @@ -1334,8 +1338,4 @@ GOPHER_ORCH_API void gopher_orch_print_leak_report(void) GOPHER_ORCH_NOEXCEPT; } \ } while (0) -#ifdef __cplusplus -} -#endif - #endif /* GOPHER_ORCH_FFI_H */ From 8b915fd6fdd6aac3c3a057dac9921e8bb91e873e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Mon, 29 Dec 2025 11:41:37 -0800 Subject: [PATCH 085/197] Add automatic submodule initialization to Makefile (#20) Add init-submodules target that automatically checks for any uninitialized git submodules using 'git submodule status' and initializes them before configure. This is generic and will work with any submodules added in the future. - configure now depends on init-submodules - Uses git submodule status to detect uninitialized submodules - Only runs initialization if needed (prefix '-' in status) --- Makefile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5639a8ff..bbf77bbe 100644 --- a/Makefile +++ b/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" From 04f69ae1f2a3269ea3ce0beea5a5f7b44ab84126 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Mon, 29 Dec 2025 21:38:55 -0800 Subject: [PATCH 086/197] Update upstream gopher-mcp submodule --- third_party/gopher-mcp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp index 5f6d6fd4..b1b83269 160000 --- a/third_party/gopher-mcp +++ b/third_party/gopher-mcp @@ -1 +1 @@ -Subproject commit 5f6d6fd4c70149eacab072cf9ba9a333107c0d36 +Subproject commit b1b83269eacdcb29898a9952bfcc1e1a1004a585 From 1c28af533b7f14d4d2d5325a2adefbac070b4278 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Tue, 30 Dec 2025 23:42:05 -0800 Subject: [PATCH 087/197] Update upstream gopher-mcp submodule: 5f78ef492f37b20644a9a50262aa8b0d85fc0b75 --- third_party/gopher-mcp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp index b1b83269..5f78ef49 160000 --- a/third_party/gopher-mcp +++ b/third_party/gopher-mcp @@ -1 +1 @@ -Subproject commit b1b83269eacdcb29898a9952bfcc1e1a1004a585 +Subproject commit 5f78ef492f37b20644a9a50262aa8b0d85fc0b75 From b7e80719b68b60fbe49a54317f882c89a60681de Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 31 Dec 2025 00:16:40 -0800 Subject: [PATCH 088/197] Update upstream gopher-mcp submodule: 7b2b0de7ee1209760ab3721ae6b3a8498f157497 --- third_party/gopher-mcp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp index 5f78ef49..7b2b0de7 160000 --- a/third_party/gopher-mcp +++ b/third_party/gopher-mcp @@ -1 +1 @@ -Subproject commit 5f78ef492f37b20644a9a50262aa8b0d85fc0b75 +Subproject commit 7b2b0de7ee1209760ab3721ae6b3a8498f157497 From c5a7b7deab1ebb325371e276edc6fe9e0f8ebf37 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 31 Dec 2025 16:03:34 -0800 Subject: [PATCH 089/197] Update upstream gopher-mcp submodule: 3563080b7fc72ce36f143056c2c86af0a3e7cd59 --- third_party/gopher-mcp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp index 7b2b0de7..3563080b 160000 --- a/third_party/gopher-mcp +++ b/third_party/gopher-mcp @@ -1 +1 @@ -Subproject commit 7b2b0de7ee1209760ab3721ae6b3a8498f157497 +Subproject commit 3563080b7fc72ce36f143056c2c86af0a3e7cd59 From d5495c178f54c504dd38db3df6cd8b4ab8a99dbe Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:02 +0530 Subject: [PATCH 090/197] Add LLM core types for provider integration (#24) Introduces Message, ToolCall, ToolSpec, LLMConfig, LLMResponse, Usage, and streaming types needed for LLM provider abstraction. --- include/gopher/orch/llm/llm_types.h | 273 ++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 include/gopher/orch/llm/llm_types.h diff --git a/include/gopher/orch/llm/llm_types.h b/include/gopher/orch/llm/llm_types.h new file mode 100644 index 00000000..e51bdaf9 --- /dev/null +++ b/include/gopher/orch/llm/llm_types.h @@ -0,0 +1,273 @@ +#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 From fa61b159776d6afee7d15ce1eda1c907473095ec Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:09 +0530 Subject: [PATCH 091/197] Add LLMProvider abstract interface (#24) Defines base class for LLM providers with chat(), chatStream(), validation, and factory functions for creating providers. --- include/gopher/orch/llm/llm_provider.h | 192 +++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 include/gopher/orch/llm/llm_provider.h diff --git a/include/gopher/orch/llm/llm_provider.h b/include/gopher/orch/llm/llm_provider.h new file mode 100644 index 00000000..4fe99193 --- /dev/null +++ b/include/gopher/orch/llm/llm_provider.h @@ -0,0 +1,192 @@ +#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 From 24a3d52d50ae29e5bb0b4fb1bad8f411e2a2079b Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:14 +0530 Subject: [PATCH 092/197] Add OpenAI provider header (#24) Declares OpenAIProvider class supporting GPT-4, GPT-3.5-turbo, and o1 models with Azure OpenAI compatibility. --- include/gopher/orch/llm/openai_provider.h | 141 ++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 include/gopher/orch/llm/openai_provider.h diff --git a/include/gopher/orch/llm/openai_provider.h b/include/gopher/orch/llm/openai_provider.h new file mode 100644 index 00000000..a9ab673a --- /dev/null +++ b/include/gopher/orch/llm/openai_provider.h @@ -0,0 +1,141 @@ +#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 From 1e18bb7b8b53e3d281231ad4bb4833be7fb09386 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:23 +0530 Subject: [PATCH 093/197] Implement OpenAI provider (#24) Adds HTTP-based chat completion with tool calling support, request/response parsing, and error handling for OpenAI API. --- src/gopher/orch/llm/openai_provider.cpp | 412 ++++++++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 src/gopher/orch/llm/openai_provider.cpp diff --git a/src/gopher/orch/llm/openai_provider.cpp b/src/gopher/orch/llm/openai_provider.cpp new file mode 100644 index 00000000..2c5ab939 --- /dev/null +++ b/src/gopher/orch/llm/openai_provider.cpp @@ -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) { + // Create HTTP client for API calls + http_client = std::make_shared(); + } + + 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.dump(); + + 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 (!result.isOk()) { + callback(Result::error(result.error())); + return; + } + + auto& response = result.value(); + 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"].get(); + } + } 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(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( + 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( + 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"].is_null()) { + result.finish_reason = choice["finish_reason"].get(); + } + + // Parse message + if (choice.contains("message")) { + const auto& msg = choice["message"]; + + // Role + if (msg.contains("role")) { + result.message.role = parseRole(msg["role"].get()); + } else { + result.message.role = Role::ASSISTANT; + } + + // Content + if (msg.contains("content") && !msg["content"].is_null()) { + result.message.content = msg["content"].get(); + } + + // Tool calls + if (msg.contains("tool_calls") && !msg["tool_calls"].is_null()) { + std::vector tool_calls; + for (const auto& tc : msg["tool_calls"]) { + ToolCall call; + call.id = tc["id"].get(); + + if (tc.contains("function")) { + call.name = tc["function"]["name"].get(); + if (tc["function"].contains("arguments")) { + std::string args_str = tc["function"]["arguments"].get(); + 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.value("prompt_tokens", 0); + u.completion_tokens = usage.value("completion_tokens", 0); + u.total_tokens = usage.value("total_tokens", 0); + result.usage = u; + } + + return Result::ok(std::move(result)); + + } catch (const std::exception& e) { + return Result::error( + 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.dump(); + 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::ok(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 From 8254d75ba70bca071e968a4fd26f58502af43d26 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:37 +0530 Subject: [PATCH 094/197] Add Anthropic provider header (#24) Declares AnthropicProvider class supporting Claude 3.5 Sonnet, Opus, and Haiku models with beta features support. --- include/gopher/orch/llm/anthropic_provider.h | 138 +++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 include/gopher/orch/llm/anthropic_provider.h diff --git a/include/gopher/orch/llm/anthropic_provider.h b/include/gopher/orch/llm/anthropic_provider.h new file mode 100644 index 00000000..88c0ad7d --- /dev/null +++ b/include/gopher/orch/llm/anthropic_provider.h @@ -0,0 +1,138 @@ +#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 From 1acbf99c6944a0bbc0d69c96c9d7009ed5904797 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:43 +0530 Subject: [PATCH 095/197] Implement Anthropic provider (#24) Adds Messages API integration with tool_use support, handles Anthropic's content block format and separate system prompt. --- src/gopher/orch/llm/anthropic_provider.cpp | 416 +++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 src/gopher/orch/llm/anthropic_provider.cpp diff --git a/src/gopher/orch/llm/anthropic_provider.cpp b/src/gopher/orch/llm/anthropic_provider.cpp new file mode 100644 index 00000000..b516004d --- /dev/null +++ b/src/gopher/orch/llm/anthropic_provider.cpp @@ -0,0 +1,416 @@ +// Anthropic Provider Implementation + +#include "gopher/orch/llm/anthropic_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 AnthropicProvider::Impl { + public: + AnthropicConfig config; + HttpClientPtr http_client; + mutable std::mutex mutex; + + explicit Impl(const AnthropicConfig& cfg) : config(cfg) { + http_client = std::make_shared(); + } + + 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 +// ═══════════════════════════════════════════════════════════════════════════ + +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) { + return Ptr(new AnthropicProvider(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.dump(); + + 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 (!result.isOk()) { + callback(Result::error(result.error())); + return; + } + + auto& response = result.value(); + 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"].get(); + } + } 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(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( + 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"].is_null()) { + std::string stop_reason = response["stop_reason"].get(); + // 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"].is_array()) { + std::string text_content; + std::vector tool_calls; + + for (const auto& block : response["content"]) { + std::string block_type = block.value("type", ""); + + if (block_type == "text") { + if (!text_content.empty()) { + text_content += "\n"; + } + text_content += block["text"].get(); + + } else if (block_type == "tool_use") { + ToolCall tc; + tc.id = block["id"].get(); + tc.name = block["name"].get(); + 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.value("input_tokens", 0); + u.completion_tokens = usage.value("output_tokens", 0); + u.total_tokens = u.prompt_tokens + u.completion_tokens; + result.usage = u; + } + + return Result::ok(std::move(result)); + + } catch (const std::exception& e) { + return Result::error( + 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; + json["input_schema"] = tool.parameters; + 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 From 4950cebb0cde643d823e647684b9ed631d43d806 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:48 +0530 Subject: [PATCH 096/197] Add LLM provider factory (#24) Implements createProvider() and convenience functions for OpenAI, Anthropic, Ollama, and custom provider creation. --- src/gopher/orch/llm/llm_factory.cpp | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/gopher/orch/llm/llm_factory.cpp diff --git a/src/gopher/orch/llm/llm_factory.cpp b/src/gopher/orch/llm/llm_factory.cpp new file mode 100644 index 00000000..54954c04 --- /dev/null +++ b/src/gopher/orch/llm/llm_factory.cpp @@ -0,0 +1,59 @@ +// LLM Provider Factory Implementation + +#include "gopher/orch/llm/llm_provider.h" +#include "gopher/orch/llm/openai_provider.h" +#include "gopher/orch/llm/anthropic_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 From 7b1e4636bf942eb3568c9e7b852f995d571e7ecb Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:57:56 +0530 Subject: [PATCH 097/197] Add LLM module header (#24) Unified include for all LLM provider types, interfaces, and implementations with usage documentation. --- include/gopher/orch/llm/llm.h | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 include/gopher/orch/llm/llm.h diff --git a/include/gopher/orch/llm/llm.h b/include/gopher/orch/llm/llm.h new file mode 100644 index 00000000..62df6a47 --- /dev/null +++ b/include/gopher/orch/llm/llm.h @@ -0,0 +1,54 @@ +#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/openai_provider.h" +#include "gopher/orch/llm/anthropic_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 From c75016d020f38184532425680da52c08bb4b1c0d Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:58:02 +0530 Subject: [PATCH 098/197] Integrate LLM module into main orch header (#24) Adds LLM include and re-exports all LLM types at gopher::orch namespace for convenient access. --- include/gopher/orch/orch.h | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 1fde4bbd..9320176f 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -46,6 +46,9 @@ // Human-in-the-Loop #include "gopher/orch/human/approval.h" +// LLM Providers +#include "gopher/orch/llm/llm.h" + // Server abstraction #include "gopher/orch/server/mock_server.h" #include "gopher/orch/server/server.h" @@ -189,6 +192,31 @@ 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 + // FFI C++ utilities (conditional) // The C API (gopher_orch_*) is always available in the global namespace #ifdef GOPHER_ORCH_WITH_FFI From fc811f11250c193159632faa2845e47ccb245ae0 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 16:58:07 +0530 Subject: [PATCH 099/197] Add LLM sources to build configuration (#24) Includes openai_provider, anthropic_provider, and llm_factory in the build when gopher-mcp is available. --- src/CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 136c8b58..e84b53bc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,10 +15,21 @@ if(NOT BUILD_WITHOUT_GOPHER_MCP) ) 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.cpp + gopher/orch/llm/anthropic_provider.cpp + gopher/orch/llm/llm_factory.cpp + ) +endif() + # Combine all sources set(GOPHER_ORCH_SOURCES ${ORCH_CORE_SOURCES} ${ORCH_MCP_SOURCES} + ${ORCH_LLM_SOURCES} ) # Build static library From e7f8a248aa5b556ead26139bbb8baa2c8cb35bcd Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:03:40 +0530 Subject: [PATCH 100/197] Add Agent core types for ReAct pattern (#24) Introduces AgentConfig, AgentState, AgentStatus, AgentStep, AgentResult, and callback types for agent execution tracking. --- include/gopher/orch/agent/agent_types.h | 255 ++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 include/gopher/orch/agent/agent_types.h diff --git a/include/gopher/orch/agent/agent_types.h b/include/gopher/orch/agent/agent_types.h new file mode 100644 index 00000000..ebdfb106 --- /dev/null +++ b/include/gopher/orch/agent/agent_types.h @@ -0,0 +1,255 @@ +#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 +struct AgentState { + AgentStatus status = AgentStatus::IDLE; + + // Conversation history + std::vector messages; + + // Steps taken + std::vector steps; + + // Current iteration + int current_iteration = 0; + + // Token usage + Usage total_usage; + + // Timing + std::chrono::steady_clock::time_point start_time; + std::chrono::milliseconds elapsed{0}; + + // Error info (if failed) + 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; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// 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 From 4ba82b39582e802b6482ccae804c24e5801f157e Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:03:48 +0530 Subject: [PATCH 101/197] Add ToolRegistry for unified tool management (#24) Manages tools from multiple sources including local lambdas, MCP servers, and REST endpoints with async execution support. --- include/gopher/orch/agent/tool_registry.h | 328 ++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 include/gopher/orch/agent/tool_registry.h diff --git a/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h new file mode 100644 index 00000000..15189cab --- /dev/null +++ b/include/gopher/orch/agent/tool_registry.h @@ -0,0 +1,328 @@ +#pragma once + +// ToolRegistry - Unified tool management for agents +// +// Manages tools from multiple sources: +// - Local lambda functions +// - MCP servers (via Server interface) +// - REST endpoints +// +// Provides tool specs for LLM and executes tool calls. +// +// Usage: +// ToolRegistry registry; +// +// // Add local tool +// registry.addTool("calculator", "Perform calculations", schema, +// [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { +// // Implementation... +// }); +// +// // Add tools from MCP server +// registry.addServer(mcpServer); +// +// // Get specs for LLM +// auto specs = registry.getToolSpecs(); +// +// // Execute tool call +// registry.executeTool("calculator", args, dispatcher, callback); + +#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" + +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; + +// Internal tool entry +struct ToolEntry { + ToolSpec spec; + ToolFunction function; + ServerPtr server; // nullptr for local tools + + bool isLocal() const { return server == nullptr; } + bool isRemote() const { return server != nullptr; } +}; + +// ToolRegistry - Manages tools from multiple sources +// +// Thread Safety: +// - Configuration methods (addTool, addServer) should be called before use +// - executeTool and getToolSpecs 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(); } + + // ═══════════════════════════════════════════════════════════════════════════ + // 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 + void addServer(ServerPtr server, Dispatcher& dispatcher) { + if (!server) return; + + // Store server reference + { + std::lock_guard lock(mutex_); + servers_.push_back(server); + } + + // List and register tools + server->listTools(dispatcher, [this, server](Result> result) { + if (!result.isOk()) return; + + std::lock_guard lock(mutex_); + for (const auto& info : result.value()) { + ToolEntry entry; + entry.spec.name = info.name; + entry.spec.description = info.description; + entry.spec.parameters = info.input_schema; + entry.server = server; + + // Use prefixed name to avoid conflicts + std::string key = server->name() + ":" + info.name; + tools_[key] = std::move(entry); + + // Also register without prefix if no conflict + if (tools_.find(info.name) == tools_.end()) { + tools_[info.name] = tools_[key]; + } + } + }); + } + + // Add specific tool from a server + 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; + + // Note: Tool spec details will be fetched when listTools is called + 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; + specs.reserve(tools_.size()); + + for (const auto& pair : tools_) { + specs.push_back(pair.second.spec); + } + + return specs; + } + + // 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 + size_t toolCount() const { + std::lock_guard lock(mutex_); + return tools_.size(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TOOL EXECUTION + // ═══════════════════════════════════════════════════════════════════════════ + + // Execute a tool by name + void executeTool(const std::string& name, + const JsonValue& arguments, + Dispatcher& dispatcher, + JsonCallback callback) { + ToolEntry entry; + + { + std::lock_guard lock(mutex_); + auto it = tools_.find(name); + if (it == tools_.end()) { + dispatcher.post([callback = std::move(callback), name]() { + callback(Result::error( + Error(-1, "Tool not found: " + name))); + }); + return; + } + entry = it->second; + } + + if (entry.isLocal()) { + // Execute local function + entry.function(arguments, dispatcher, std::move(callback)); + } else { + // Execute on remote server + RunnableConfig config; + entry.server->callTool(entry.spec.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)); + } + }); + + // If not parallel, wait for completion before next call + // Note: True sequential execution would require callback chaining + // This is a simplified version that still executes in parallel + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 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(); + } + + private: + mutable std::mutex mutex_; + std::map tools_; + std::vector servers_; +}; + +// Convenience function to create registry +inline ToolRegistryPtr makeToolRegistry() { + return ToolRegistry::create(); +} + +} // namespace agent +} // namespace orch +} // namespace gopher From 595d1a29191272d96ee57fd3fb2fd75a6c0bbf5e Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:03:55 +0530 Subject: [PATCH 102/197] Add Agent interface and ReActAgent header (#24) Defines abstract Agent class and ReActAgent implementation with run, cancel, and progress monitoring capabilities. --- include/gopher/orch/agent/agent.h | 169 ++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 include/gopher/orch/agent/agent.h diff --git a/include/gopher/orch/agent/agent.h b/include/gopher/orch/agent/agent.h new file mode 100644 index 00000000..369b550b --- /dev/null +++ b/include/gopher/orch/agent/agent.h @@ -0,0 +1,169 @@ +#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_registry.h" +#include "gopher/orch/llm/llm_provider.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::llm; + +// Forward declaration +class Agent; +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); + + private: + explicit ReActAgent(LLMProviderPtr provider, + ToolRegistryPtr tools, + const AgentConfig& config); + + // 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 From 190ca9c6c7dfd8948d10ddaf70c32a314463ebdd Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:04:03 +0530 Subject: [PATCH 103/197] Implement ReActAgent with reasoning-acting loop (#24) Executes LLM calls, handles tool execution, feeds results back, and iterates until completion or max iterations reached. --- src/gopher/orch/agent/agent.cpp | 434 ++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 src/gopher/orch/agent/agent.cpp diff --git a/src/gopher/orch/agent/agent.cpp b/src/gopher/orch/agent/agent.cpp new file mode 100644 index 00000000..20f727c7 --- /dev/null +++ b/src/gopher/orch/agent/agent.cpp @@ -0,0 +1,434 @@ +// ReActAgent Implementation + +#include "gopher/orch/agent/agent.h" + +#include +#include + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::core; + +// ═══════════════════════════════════════════════════════════════════════════ +// IMPLEMENTATION +// ═══════════════════════════════════════════════════════════════════════════ + +class ReActAgent::Impl { + public: + LLMProviderPtr provider; + ToolRegistryPtr tools; + 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()), + 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(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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( + Error(AgentError::UNKNOWN, "Agent is already running"))); + }); + return; + } + + // Check provider + if (!impl_->provider) { + dispatcher.post([callback = std::move(callback)]() { + callback(Result::error( + 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 (!result.isOk()) { + impl_->state.error = result.error(); + completeRun(AgentStatus::FAILED, dispatcher); + return; + } + + auto duration = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start_time); + + // Create step record + AgentStep step; + step.step_number = impl_->state.current_iteration; + step.llm_message = result.value().message; + step.llm_usage = result.value().usage; + step.llm_duration = duration; + + // Handle response + handleLLMResponse(result.value(), dispatcher); + + // Record step (will be updated with tool results if needed) + impl_->recordStep(step); + }); +} + +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_->tools) { + // No tools 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 + auto start_time = std::chrono::steady_clock::now(); + + impl_->tools->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 (results[i].isOk()) { + exec.output = results[i].value(); + exec.success = true; + } else { + exec.success = false; + exec.error_message = results[i].error().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 (results[i].isOk()) { + result_content = results[i].value().dump(); + } else { + result_content = "Error: " + results[i].error().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::ok(std::move(result))); + } else { + callback(Result::error( + 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; +} + +} // namespace agent +} // namespace orch +} // namespace gopher From 3b1e7b927bbdd2b60239728c23ff5d76d9f0f3f5 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:04:11 +0530 Subject: [PATCH 104/197] Add Agent module header (#24) Unified include for agent framework with types, tool registry, and ReActAgent implementation along with usage documentation. --- include/gopher/orch/agent/agent_module.h | 62 ++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 include/gopher/orch/agent/agent_module.h diff --git a/include/gopher/orch/agent/agent_module.h b/include/gopher/orch/agent/agent_module.h new file mode 100644 index 00000000..3ab1ccbb --- /dev/null +++ b/include/gopher/orch/agent/agent_module.h @@ -0,0 +1,62 @@ +#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" + +// 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 From 226819afe85e36f8cf852e80770af8c9c4bb3809 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:04:20 +0530 Subject: [PATCH 105/197] Integrate Agent module into main orch header (#24) Adds agent include and re-exports Agent, ToolRegistry, and related types at gopher::orch namespace for convenient access. --- include/gopher/orch/orch.h | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 9320176f..bc5660a4 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -49,6 +49,9 @@ // 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" @@ -217,6 +220,27 @@ 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; +namespace AgentError = agent::AgentError; // Namespace alias for error codes + // FFI C++ utilities (conditional) // The C API (gopher_orch_*) is always available in the global namespace #ifdef GOPHER_ORCH_WITH_FFI From 4e43e6c8af98a7bf0129915432539e7fa22dd7b4 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:04:29 +0530 Subject: [PATCH 106/197] Add Agent sources to build configuration (#24) Includes agent.cpp in the build when gopher-mcp is available since it depends on LLM providers for HTTP client. --- src/CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e84b53bc..c6cc4790 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,11 +25,20 @@ if(NOT BUILD_WITHOUT_GOPHER_MCP) ) endif() +# Agent sources (requires LLM providers) +set(ORCH_AGENT_SOURCES "") +if(NOT BUILD_WITHOUT_GOPHER_MCP) + set(ORCH_AGENT_SOURCES + gopher/orch/agent/agent.cpp + ) +endif() + # Combine all sources set(GOPHER_ORCH_SOURCES ${ORCH_CORE_SOURCES} ${ORCH_MCP_SOURCES} ${ORCH_LLM_SOURCES} + ${ORCH_AGENT_SOURCES} ) # Build static library From aac8c3b106a165071f3afd5a7d730366c9a67420 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:14:10 +0530 Subject: [PATCH 107/197] Fix ToolInfo to ToolSpec conversion and improve integration (#24) Adds toToolSpec/toToolInfo conversion utilities, fixes inputSchema reference, tracks original_name for aliased tools, and adds methods for sync server registration and individual tool spec access. --- include/gopher/orch/agent/tool_registry.h | 109 +++++++++++++++++++--- 1 file changed, 98 insertions(+), 11 deletions(-) diff --git a/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h index 15189cab..0f264f22 100644 --- a/include/gopher/orch/agent/tool_registry.h +++ b/include/gopher/orch/agent/tool_registry.h @@ -55,11 +55,34 @@ using ToolFunction = std::function; +// ═══════════════════════════════════════════════════════════════════════════ +// CONVERSION UTILITIES +// ═══════════════════════════════════════════════════════════════════════════ + +// Convert ToolInfo (from Server) to ToolSpec (for LLM) +inline ToolSpec toToolSpec(const ToolInfo& info) { + ToolSpec spec; + spec.name = info.name; + spec.description = info.description; + spec.parameters = info.inputSchema; + return spec; +} + +// Convert ToolSpec (from LLM) to ToolInfo (for Server) +inline ToolInfo toToolInfo(const ToolSpec& spec) { + ToolInfo 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; } @@ -127,7 +150,7 @@ class ToolRegistry { // REMOTE TOOLS (MCP/REST Servers) // ═══════════════════════════════════════════════════════════════════════════ - // Add all tools from a server + // Add all tools from a server (async - fetches tool list) void addServer(ServerPtr server, Dispatcher& dispatcher) { if (!server) return; @@ -144,24 +167,65 @@ class ToolRegistry { std::lock_guard lock(mutex_); for (const auto& info : result.value()) { ToolEntry entry; - entry.spec.name = info.name; - entry.spec.description = info.description; - entry.spec.parameters = info.input_schema; + entry.spec = toToolSpec(info); // Use conversion utility entry.server = server; + entry.original_name = info.name; // Use prefixed name to avoid conflicts - std::string key = server->name() + ":" + info.name; - tools_[key] = std::move(entry); + 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] = tools_[key]; + tools_[info.name] = entry; } } }); } - // Add specific tool from a server + // 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); + + 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 ToolInfo + void addServerTool(ServerPtr server, + const ToolInfo& 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 = "") { @@ -172,8 +236,8 @@ class ToolRegistry { ToolEntry entry; entry.spec.name = alias.empty() ? tool_name : alias; entry.server = server; + entry.original_name = tool_name; - // Note: Tool spec details will be fetched when listTools is called std::string key = alias.empty() ? tool_name : alias; tools_[key] = std::move(entry); } @@ -196,6 +260,26 @@ class ToolRegistry { 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_); @@ -250,9 +334,12 @@ class ToolRegistry { // Execute local function entry.function(arguments, dispatcher, std::move(callback)); } else { - // Execute on remote server + // Execute on remote server using original name RunnableConfig config; - entry.server->callTool(entry.spec.name, arguments, config, dispatcher, + 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)); } } From bbae4b42b8cc654b74036e3054290c0ab2c1d256 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:14:18 +0530 Subject: [PATCH 108/197] Export toToolSpec and toToolInfo conversion utilities (#24) Adds conversion functions to gopher::orch namespace for seamless integration between Server (ToolInfo) and LLM (ToolSpec) types. --- include/gopher/orch/orch.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index bc5660a4..63fd97cb 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -239,6 +239,8 @@ using agent::ToolExecution; using agent::ToolFunction; using agent::ToolRegistry; using agent::ToolRegistryPtr; +using agent::toToolInfo; // Convert ToolSpec -> ToolInfo +using agent::toToolSpec; // Convert ToolInfo -> ToolSpec namespace AgentError = agent::AgentError; // Namespace alias for error codes // FFI C++ utilities (conditional) From 9712fc5fd471657c5274e2cee01bd9bea21323ed Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:23:57 +0530 Subject: [PATCH 109/197] Add ToolDefinition structs for JSON-based tool configuration (#24) Introduces core data structures for tool configuration: - ToolDefinition with REST endpoint and MCP reference support - MCPServerDefinition for stdio, HTTP-SSE, and WebSocket transports - AuthPreset for OAuth2, API key, and Bearer token authentication - RegistryConfig for complete tool registry configuration - Builder patterns for fluent configuration --- include/gopher/orch/agent/tool_definition.h | 350 ++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 include/gopher/orch/agent/tool_definition.h diff --git a/include/gopher/orch/agent/tool_definition.h b/include/gopher/orch/agent/tool_definition.h new file mode 100644 index 00000000..75e6b526 --- /dev/null +++ b/include/gopher/orch/agent/tool_definition.h @@ -0,0 +1,350 @@ +#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 MCPReference { + std::string server_name; // Name of registered MCP server + std::string tool_name; // Tool name on that server + + MCPReference() = default; + MCPReference(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 = MCPReference(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 From d33bc6b1f15e050f569316b4437728b1a183cc6d Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:24:04 +0530 Subject: [PATCH 110/197] Add ConfigLoader for JSON config and env file parsing (#24) Implements configuration loading with: - JSON parsing for RegistryConfig, ToolDefinition, MCPServerDefinition - Environment variable substitution (${VAR_NAME} syntax) - .env file loading with quote handling - File I/O operations for config and env files --- include/gopher/orch/agent/config_loader.h | 386 ++++++++++++++++++++++ src/gopher/orch/agent/config_loader.cpp | 71 ++++ 2 files changed, 457 insertions(+) create mode 100644 include/gopher/orch/agent/config_loader.h create mode 100644 src/gopher/orch/agent/config_loader.cpp diff --git a/include/gopher/orch/agent/config_loader.h b/include/gopher/orch/agent/config_loader.h new file mode 100644 index 00000000..07fabafd --- /dev/null +++ b/include/gopher/orch/agent/config_loader.h @@ -0,0 +1,386 @@ +#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 + Result 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); + 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.value("type", "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.value("value", "")); + auth.header = json.value("header", "Authorization"); + + return Result::ok(std::move(auth)); +} + +inline Result ConfigLoader::parseMCPServerDefinition( + const JsonValue& json) { + MCPServerDefinition def; + + def.name = json.value("name", ""); + if (def.name.empty()) { + return Result::error( + Error(-1, "MCP server definition missing 'name'")); + } + + std::string transport = json.value("transport", "stdio"); + def.transport = parseTransportType(transport); + + // Parse transport-specific config + switch (def.transport) { + case MCPServerDefinition::TransportType::STDIO: { + if (json.contains("stdio")) { + const auto& stdio = json["stdio"]; + MCPServerDefinition::StdioConfig cfg; + cfg.command = substituteEnvVars(stdio.value("command", "")); + + if (stdio.contains("args") && stdio["args"].is_array()) { + for (const auto& arg : stdio["args"]) { + cfg.args.push_back(substituteEnvVars(arg.get())); + } + } + + if (stdio.contains("env") && stdio["env"].is_object()) { + for (auto it = stdio["env"].begin(); it != stdio["env"].end(); ++it) { + cfg.env[it.key()] = substituteEnvVars(it.value().get()); + } + } + + cfg.working_directory = stdio.value("working_directory", ""); + def.stdio_config = std::move(cfg); + } + break; + } + + case MCPServerDefinition::TransportType::HTTP_SSE: { + if (json.contains("http_sse")) { + const auto& sse = json["http_sse"]; + MCPServerDefinition::HttpSseConfig cfg; + cfg.url = substituteEnvVars(sse.value("url", "")); + cfg.verify_ssl = sse.value("verify_ssl", true); + + if (sse.contains("headers") && sse["headers"].is_object()) { + for (auto it = sse["headers"].begin(); it != sse["headers"].end(); ++it) { + cfg.headers[it.key()] = substituteEnvVars(it.value().get()); + } + } + + def.http_sse_config = std::move(cfg); + } + break; + } + + case MCPServerDefinition::TransportType::WEBSOCKET: { + if (json.contains("websocket")) { + const auto& ws = json["websocket"]; + MCPServerDefinition::WebSocketConfig cfg; + cfg.url = substituteEnvVars(ws.value("url", "")); + cfg.verify_ssl = ws.value("verify_ssl", true); + + if (ws.contains("headers") && ws["headers"].is_object()) { + for (auto it = ws["headers"].begin(); it != ws["headers"].end(); ++it) { + cfg.headers[it.key()] = substituteEnvVars(it.value().get()); + } + } + + def.websocket_config = std::move(cfg); + } + break; + } + } + + // Parse timeouts + if (json.contains("connect_timeout_ms")) { + def.connect_timeout = std::chrono::milliseconds(json["connect_timeout_ms"].get()); + } + if (json.contains("request_timeout_ms")) { + def.request_timeout = std::chrono::milliseconds(json["request_timeout_ms"].get()); + } + if (json.contains("max_retries")) { + def.max_retries = json["max_retries"].get(); + } + + return Result::ok(std::move(def)); +} + +inline Result ConfigLoader::parseToolDefinition( + const JsonValue& json) { + ToolDefinition def; + + def.name = json.value("name", ""); + if (def.name.empty()) { + return Result::error( + Error(-1, "Tool definition missing 'name'")); + } + + def.description = json.value("description", ""); + + 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.value("method", "GET")); + rest.url = substituteEnvVars(ep.value("url", "")); + + if (ep.contains("headers") && ep["headers"].is_object()) { + for (auto it = ep["headers"].begin(); it != ep["headers"].end(); ++it) { + rest.headers[it.key()] = substituteEnvVars(it.value().get()); + } + } + + if (ep.contains("query_params") && ep["query_params"].is_object()) { + for (auto it = ep["query_params"].begin(); it != ep["query_params"].end(); ++it) { + rest.query_params[it.key()] = substituteEnvVars(it.value().get()); + } + } + + if (ep.contains("path_params") && ep["path_params"].is_object()) { + for (auto it = ep["path_params"].begin(); it != ep["path_params"].end(); ++it) { + rest.path_params[it.key()] = it.value().get(); + } + } + + if (ep.contains("body_mapping") && ep["body_mapping"].is_object()) { + for (auto it = ep["body_mapping"].begin(); it != ep["body_mapping"].end(); ++it) { + rest.body_mapping[it.key()] = it.value().get(); + } + } + + rest.response_path = ep.value("response_path", ""); + def.rest_endpoint = std::move(rest); + } + + // Parse MCP reference + if (json.contains("mcp_reference")) { + const auto& ref = json["mcp_reference"]; + ToolDefinition::MCPReference mcp; + mcp.server_name = ref.value("server_name", ""); + mcp.tool_name = ref.value("tool_name", ""); + def.mcp_reference = std::move(mcp); + } + + // Parse tags + if (json.contains("tags") && json["tags"].is_array()) { + for (const auto& tag : json["tags"]) { + def.tags.push_back(tag.get()); + } + } + + def.require_approval = json.value("require_approval", false); + + return Result::ok(std::move(def)); +} + +inline Result ConfigLoader::loadFromJson(const JsonValue& json) { + RegistryConfig config; + + config.name = json.value("name", "tool-registry"); + config.base_url = substituteEnvVars(json.value("base_url", "")); + + // Parse default headers + if (json.contains("default_headers") && json["default_headers"].is_object()) { + for (auto it = json["default_headers"].begin(); + it != json["default_headers"].end(); ++it) { + config.default_headers[it.key()] = + substituteEnvVars(it.value().get()); + } + } + + // Parse auth presets + if (json.contains("auth_presets") && json["auth_presets"].is_object()) { + for (auto it = json["auth_presets"].begin(); + it != json["auth_presets"].end(); ++it) { + auto auth_result = parseAuthPreset(it.value()); + if (auth_result.isOk()) { + config.auth_presets[it.key()] = auth_result.value(); + } + } + } + + // Parse MCP servers + if (json.contains("mcp_servers") && json["mcp_servers"].is_array()) { + for (const auto& server_json : json["mcp_servers"]) { + auto server_result = parseMCPServerDefinition(server_json); + if (server_result.isOk()) { + config.mcp_servers.push_back(std::move(server_result.value())); + } + } + } + + // Parse tools + if (json.contains("tools") && json["tools"].is_array()) { + for (const auto& tool_json : json["tools"]) { + auto tool_result = parseToolDefinition(tool_json); + if (tool_result.isOk()) { + config.tools.push_back(std::move(tool_result.value())); + } + } + } + + return Result::ok(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( + Error(-1, std::string("JSON parse error: ") + e.what())); + } +} + +} // namespace agent +} // namespace orch +} // namespace gopher diff --git a/src/gopher/orch/agent/config_loader.cpp b/src/gopher/orch/agent/config_loader.cpp new file mode 100644 index 00000000..339dc694 --- /dev/null +++ b/src/gopher/orch/agent/config_loader.cpp @@ -0,0 +1,71 @@ +// ConfigLoader Implementation - File I/O operations + +#include "gopher/orch/agent/config_loader.h" + +#include +#include + +namespace gopher { +namespace orch { +namespace agent { + +Result ConfigLoader::loadEnvFile(const std::string& path) { + std::ifstream file(path); + if (!file.is_open()) { + return Result::error(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 Result::ok(); +} + +Result ConfigLoader::loadFromFile(const std::string& path) { + std::ifstream file(path); + if (!file.is_open()) { + return Result::error( + Error(-1, "Cannot open config file: " + path)); + } + + std::stringstream buffer; + buffer << file.rdbuf(); + + return loadFromString(buffer.str()); +} + +} // namespace agent +} // namespace orch +} // namespace gopher From 2ce844008102bac6b7ca78724d235f8273db412a Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:24:11 +0530 Subject: [PATCH 111/197] Add RESTToolAdapter for REST endpoint to tool conversion (#24) Creates executable tools from REST endpoint definitions: - Path parameter substitution (/users/{id}) - Query parameter mapping from JSON input - Request body mapping with JSONPath - Response path extraction - Environment variable substitution in URLs and headers - URL encoding utilities --- include/gopher/orch/agent/rest_tool_adapter.h | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 include/gopher/orch/agent/rest_tool_adapter.h diff --git a/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h new file mode 100644 index 00000000..fed9ad20 --- /dev/null +++ b/include/gopher/orch/agent/rest_tool_adapter.h @@ -0,0 +1,284 @@ +#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 "gopher/orch/agent/tool_definition.h" +#include "gopher/orch/server/rest_server.h" + +namespace gopher { +namespace orch { +namespace agent { + +using namespace gopher::orch::server; + +// ═══════════════════════════════════════════════════════════════════════════ +// 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.is_array() || index >= static_cast(current.size())) { + return JsonValue(); + } + current = current[index]; + } else { + if (!current.is_object() || !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.is_null()) { + return ""; + } + if (value.is_string()) { + return value.get(); + } + return value.dump(); +} + +// 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& [param, json_path] : endpoint.path_params) { + std::string value = extractJsonPathString(input, json_path); + std::regex param_regex("\\{" + param + "\\}"); + 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& [param, json_path] : endpoint.query_params) { + std::string value = substituteEnvVars(extractJsonPathString(input, json_path)); + if (!value.empty()) { + url += (has_query ? "&" : "?"); + url += urlEncode(param) + "=" + urlEncode(value); + has_query = true; + } + } + } + + // Build headers + std::map headers = default_headers_; + for (const auto& [key, value] : endpoint.headers) { + headers[key] = substituteEnvVars(value); + } + 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& [key, json_path] : endpoint.body_mapping) { + body_json[key] = extractJsonPath(input, json_path); + } + body = body_json.dump(); + } else { + body = input.dump(); + } + } + + // Make request + http_client_->request( + endpoint.method, url, headers, body, dispatcher, + [endpoint, callback = std::move(callback)](Result result) { + if (!result.isOk()) { + callback(Result::error(result.error())); + return; + } + + auto& response = result.value(); + if (!response.isSuccess()) { + callback(Result::error( + 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::ok(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 From c47cd9e30a9ca92680ae91d9f7a413a9355a5113 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:24:18 +0530 Subject: [PATCH 112/197] Extend ToolRegistry with config loading and MCP server management (#24) Adds comprehensive configuration support: - loadFromFile/loadFromString for JSON config loading - registerTool for REST, MCP reference, and lambda tools - addMCPServer with stdio, HTTP-SSE, WebSocket transports - Environment variable management with setEnv and loadEnvFile - MCP server lookup by name --- include/gopher/orch/agent/tool_registry.h | 90 ++++++- src/gopher/orch/agent/tool_registry.cpp | 315 ++++++++++++++++++++++ 2 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 src/gopher/orch/agent/tool_registry.cpp diff --git a/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h index 0f264f22..ba72b751 100644 --- a/include/gopher/orch/agent/tool_registry.h +++ b/include/gopher/orch/agent/tool_registry.h @@ -5,20 +5,24 @@ // Manages tools from multiple sources: // - Local lambda functions // - MCP servers (via Server interface) -// - REST endpoints +// - REST endpoints (via JSON config) +// - JSON configuration files // // Provides tool specs for LLM and executes tool calls. // // Usage: // ToolRegistry registry; // -// // Add local tool +// // Option 1: Load from JSON config +// registry.loadFromFile("tools.json", dispatcher, callback); +// +// // Option 2: Add tools programmatically // registry.addTool("calculator", "Perform calculations", schema, // [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { // // Implementation... // }); // -// // Add tools from MCP server +// // Option 3: Add from MCP server // registry.addServer(mcpServer); // // // Get specs for LLM @@ -27,6 +31,7 @@ // // Execute tool call // registry.executeTool("calculator", args, dispatcher, callback); +#include #include #include #include @@ -38,6 +43,19 @@ #include "gopher/orch/llm/llm_types.h" #include "gopher/orch/server/server.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 { @@ -399,10 +417,76 @@ class ToolRegistry { 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 + Result 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 + Result 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 }; // Convenience function to create registry diff --git a/src/gopher/orch/agent/tool_registry.cpp b/src/gopher/orch/agent/tool_registry.cpp new file mode 100644 index 00000000..2fad2848 --- /dev/null +++ b/src/gopher/orch/agent/tool_registry.cpp @@ -0,0 +1,315 @@ +// ToolRegistry Config Loading Implementation + +#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 (!result.isOk()) { + dispatcher.post([callback = std::move(callback), err = result.error()]() { + callback(Result::error(err)); + }); + return; + } + + loadConfig(result.value(), 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 (!result.isOk()) { + dispatcher.post([callback = std::move(callback), err = result.error()]() { + callback(Result::error(err)); + }); + return; + } + + loadConfig(result.value(), 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 (!result.isOk()) { + errors->push_back("Tool " + tool_def.name + ": " + + result.error().message); + } + } + + if (!errors->empty()) { + std::string error_msg = "Errors during config load:"; + for (const auto& e : *errors) { + error_msg += "\n - " + e; + } + callback(Result::error(Error(-1, error_msg))); + } else { + callback(Result::ok()); + } + }; + + 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]( + Result result) mutable { + if (!result.isOk()) { + errors->push_back("MCP server " + name + ": " + + result.error().message); + } + + if (--(*pending) == 0) { + on_all_connected(); + } + }); + } +} + +Result 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 Result::error( + 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 Result::error( + Error(-1, "MCP server not found: " + ref.server_name)); + } + } else { + return Result::error( + 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 Result::ok(); +} + +Result ToolRegistry::loadEnvFile(const std::string& path) { + ConfigLoader loader; + auto result = loader.loadEnvFile(path); + if (!result.isOk()) { + 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 Result::error(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 Result::ok(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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(Result::error(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(Result::error(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(Result::error(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 (!result.isOk()) { + callback(Result::error(result.error())); + return; + } + + auto server = result.value(); + + // Store in registry + { + std::lock_guard lock(mutex_); + mcp_servers_[name] = server; + servers_.push_back(server); + } + + callback(Result::ok()); + }); +#else + // MCP not available + dispatcher.post([callback = std::move(callback)]() { + callback(Result::error( + Error(-1, "MCP support not compiled (GOPHER_ORCH_WITH_MCP not defined)"))); + }); +#endif +} + +} // namespace agent +} // namespace orch +} // namespace gopher From 93bb5190c3d13bbde627525e98db84a08b9d83ea Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:24:24 +0530 Subject: [PATCH 113/197] Update agent module to include new config headers (#24) Includes tool_definition.h, config_loader.h, and rest_tool_adapter.h in the agent module for unified access to all agent components. --- include/gopher/orch/agent/agent_module.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/include/gopher/orch/agent/agent_module.h b/include/gopher/orch/agent/agent_module.h index 3ab1ccbb..f04d73d9 100644 --- a/include/gopher/orch/agent/agent_module.h +++ b/include/gopher/orch/agent/agent_module.h @@ -40,6 +40,11 @@ // Core types #include "gopher/orch/agent/agent_types.h" +// Tool definitions and configuration +#include "gopher/orch/agent/tool_definition.h" +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/agent/rest_tool_adapter.h" + // Tool management #include "gopher/orch/agent/tool_registry.h" From fd479748580537345390180d1a189842542a0453 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:24:30 +0530 Subject: [PATCH 114/197] Export tool definition and config types from orch namespace (#24) Re-exports ToolDefinition, MCPServerDefinition, RegistryConfig, AuthPreset, ConfigLoader, RESTToolAdapter, and makeRESTToolAdapter for convenient access from the orch namespace. --- include/gopher/orch/orch.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 63fd97cb..1f69882a 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -243,6 +243,16 @@ using agent::toToolInfo; // Convert ToolSpec -> ToolInfo using agent::toToolSpec; // Convert ToolInfo -> 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::MCPServerDefinition; +using agent::RegistryConfig; +using agent::RESTToolAdapter; +using agent::RESTToolAdapterPtr; +using agent::ToolDefinition; +using agent::makeRESTToolAdapter; + // FFI C++ utilities (conditional) // The C API (gopher_orch_*) is always available in the global namespace #ifdef GOPHER_ORCH_WITH_FFI From 871035bc8ae715fd28b2e12459d178cefd779f2e Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:24:37 +0530 Subject: [PATCH 115/197] Add config_loader and tool_registry sources to build (#24) Includes config_loader.cpp and tool_registry.cpp in the agent sources for the static and shared library builds. --- src/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c6cc4790..96a3394c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -30,6 +30,8 @@ set(ORCH_AGENT_SOURCES "") if(NOT BUILD_WITHOUT_GOPHER_MCP) set(ORCH_AGENT_SOURCES gopher/orch/agent/agent.cpp + gopher/orch/agent/config_loader.cpp + gopher/orch/agent/tool_registry.cpp ) endif() From f44a0f2c7fcf6704b38531a0ef94da078280325f Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 17:44:10 +0530 Subject: [PATCH 116/197] Fix MCP JSON and Result API compatibility (#24) Updates all code to use mcp::json::JsonValue API: - getString() instead of .get() - isNull(), isArray(), isObject() instead of is_null(), etc. - toString() instead of dump() Updates all code to use mcp::variant Result API: - mcp::holds_alternative() instead of .isOk() - mcp::get() instead of .value() - VoidResult instead of Result - Direct Result(value) construction instead of ::ok()/::error() Fixes C++17 decomposition declarations for C++14 compatibility. --- include/gopher/orch/agent/config_loader.h | 150 ++++++++++-------- include/gopher/orch/agent/rest_tool_adapter.h | 46 +++--- include/gopher/orch/agent/tool_registry.h | 18 +-- src/gopher/orch/agent/agent.cpp | 32 ++-- src/gopher/orch/agent/config_loader.cpp | 8 +- src/gopher/orch/agent/tool_registry.cpp | 77 +++++---- src/gopher/orch/llm/anthropic_provider.cpp | 40 ++--- src/gopher/orch/llm/openai_provider.cpp | 51 +++--- 8 files changed, 218 insertions(+), 204 deletions(-) diff --git a/include/gopher/orch/agent/config_loader.h b/include/gopher/orch/agent/config_loader.h index 07fabafd..c97baa54 100644 --- a/include/gopher/orch/agent/config_loader.h +++ b/include/gopher/orch/agent/config_loader.h @@ -52,7 +52,7 @@ class ConfigLoader { } // Load environment from .env file - Result loadEnvFile(const std::string& path); + VoidResult loadEnvFile(const std::string& path); // Substitute ${VAR_NAME} in string std::string substituteEnvVars(const std::string& input) const { @@ -143,7 +143,7 @@ inline MCPServerDefinition::TransportType ConfigLoader::parseTransportType( inline Result ConfigLoader::parseAuthPreset(const JsonValue& json) { AuthPreset auth; - std::string type = json.value("type", "bearer"); + 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") { @@ -152,23 +152,23 @@ inline Result ConfigLoader::parseAuthPreset(const JsonValue& json) { auth.type = AuthPreset::Type::BASIC; } - auth.value = substituteEnvVars(json.value("value", "")); - auth.header = json.value("header", "Authorization"); + auth.value = substituteEnvVars(json.contains("value") ? json["value"].getString() : ""); + auth.header = json.contains("header") ? json["header"].getString() : "Authorization"; - return Result::ok(std::move(auth)); + return Result(std::move(auth)); } inline Result ConfigLoader::parseMCPServerDefinition( const JsonValue& json) { MCPServerDefinition def; - def.name = json.value("name", ""); + def.name = json.contains("name") ? json["name"].getString() : ""; if (def.name.empty()) { - return Result::error( + return Result( Error(-1, "MCP server definition missing 'name'")); } - std::string transport = json.value("transport", "stdio"); + std::string transport = json.contains("transport") ? json["transport"].getString() : "stdio"; def.transport = parseTransportType(transport); // Parse transport-specific config @@ -177,21 +177,23 @@ inline Result ConfigLoader::parseMCPServerDefinition( if (json.contains("stdio")) { const auto& stdio = json["stdio"]; MCPServerDefinition::StdioConfig cfg; - cfg.command = substituteEnvVars(stdio.value("command", "")); + cfg.command = substituteEnvVars(stdio.contains("command") ? stdio["command"].getString() : ""); - if (stdio.contains("args") && stdio["args"].is_array()) { - for (const auto& arg : stdio["args"]) { - cfg.args.push_back(substituteEnvVars(arg.get())); + if (stdio.contains("args") && stdio["args"].isArray()) { + const auto& args = stdio["args"]; + for (size_t i = 0; i < args.size(); ++i) { + cfg.args.push_back(substituteEnvVars(args[i].getString())); } } - if (stdio.contains("env") && stdio["env"].is_object()) { + if (stdio.contains("env") && stdio["env"].isObject()) { for (auto it = stdio["env"].begin(); it != stdio["env"].end(); ++it) { - cfg.env[it.key()] = substituteEnvVars(it.value().get()); + auto kv = *it; + cfg.env[kv.first] = substituteEnvVars(kv.second.getString()); } } - cfg.working_directory = stdio.value("working_directory", ""); + cfg.working_directory = stdio.contains("working_directory") ? stdio["working_directory"].getString() : ""; def.stdio_config = std::move(cfg); } break; @@ -201,12 +203,13 @@ inline Result ConfigLoader::parseMCPServerDefinition( if (json.contains("http_sse")) { const auto& sse = json["http_sse"]; MCPServerDefinition::HttpSseConfig cfg; - cfg.url = substituteEnvVars(sse.value("url", "")); - cfg.verify_ssl = sse.value("verify_ssl", true); + cfg.url = substituteEnvVars(sse.contains("url") ? sse["url"].getString() : ""); + cfg.verify_ssl = sse.contains("verify_ssl") ? sse["verify_ssl"].getBool() : true; - if (sse.contains("headers") && sse["headers"].is_object()) { + if (sse.contains("headers") && sse["headers"].isObject()) { for (auto it = sse["headers"].begin(); it != sse["headers"].end(); ++it) { - cfg.headers[it.key()] = substituteEnvVars(it.value().get()); + auto kv = *it; + cfg.headers[kv.first] = substituteEnvVars(kv.second.getString()); } } @@ -219,12 +222,13 @@ inline Result ConfigLoader::parseMCPServerDefinition( if (json.contains("websocket")) { const auto& ws = json["websocket"]; MCPServerDefinition::WebSocketConfig cfg; - cfg.url = substituteEnvVars(ws.value("url", "")); - cfg.verify_ssl = ws.value("verify_ssl", true); + cfg.url = substituteEnvVars(ws.contains("url") ? ws["url"].getString() : ""); + cfg.verify_ssl = ws.contains("verify_ssl") ? ws["verify_ssl"].getBool() : true; - if (ws.contains("headers") && ws["headers"].is_object()) { + if (ws.contains("headers") && ws["headers"].isObject()) { for (auto it = ws["headers"].begin(); it != ws["headers"].end(); ++it) { - cfg.headers[it.key()] = substituteEnvVars(it.value().get()); + auto kv = *it; + cfg.headers[kv.first] = substituteEnvVars(kv.second.getString()); } } @@ -236,29 +240,29 @@ inline Result ConfigLoader::parseMCPServerDefinition( // Parse timeouts if (json.contains("connect_timeout_ms")) { - def.connect_timeout = std::chrono::milliseconds(json["connect_timeout_ms"].get()); + def.connect_timeout = std::chrono::milliseconds(json["connect_timeout_ms"].getInt()); } if (json.contains("request_timeout_ms")) { - def.request_timeout = std::chrono::milliseconds(json["request_timeout_ms"].get()); + def.request_timeout = std::chrono::milliseconds(json["request_timeout_ms"].getInt()); } if (json.contains("max_retries")) { - def.max_retries = json["max_retries"].get(); + def.max_retries = static_cast(json["max_retries"].getInt()); } - return Result::ok(std::move(def)); + return Result(std::move(def)); } inline Result ConfigLoader::parseToolDefinition( const JsonValue& json) { ToolDefinition def; - def.name = json.value("name", ""); + def.name = json.contains("name") ? json["name"].getString() : ""; if (def.name.empty()) { - return Result::error( + return Result( Error(-1, "Tool definition missing 'name'")); } - def.description = json.value("description", ""); + def.description = json.contains("description") ? json["description"].getString() : ""; if (json.contains("input_schema")) { def.input_schema = json["input_schema"]; @@ -269,34 +273,38 @@ inline Result ConfigLoader::parseToolDefinition( const auto& ep = json["rest_endpoint"]; ToolDefinition::RESTEndpoint rest; - rest.method = parseHttpMethod(ep.value("method", "GET")); - rest.url = substituteEnvVars(ep.value("url", "")); + 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"].is_object()) { + if (ep.contains("headers") && ep["headers"].isObject()) { for (auto it = ep["headers"].begin(); it != ep["headers"].end(); ++it) { - rest.headers[it.key()] = substituteEnvVars(it.value().get()); + auto kv = *it; + rest.headers[kv.first] = substituteEnvVars(kv.second.getString()); } } - if (ep.contains("query_params") && ep["query_params"].is_object()) { + if (ep.contains("query_params") && ep["query_params"].isObject()) { for (auto it = ep["query_params"].begin(); it != ep["query_params"].end(); ++it) { - rest.query_params[it.key()] = substituteEnvVars(it.value().get()); + auto kv = *it; + rest.query_params[kv.first] = substituteEnvVars(kv.second.getString()); } } - if (ep.contains("path_params") && ep["path_params"].is_object()) { + if (ep.contains("path_params") && ep["path_params"].isObject()) { for (auto it = ep["path_params"].begin(); it != ep["path_params"].end(); ++it) { - rest.path_params[it.key()] = it.value().get(); + auto kv = *it; + rest.path_params[kv.first] = kv.second.getString(); } } - if (ep.contains("body_mapping") && ep["body_mapping"].is_object()) { + if (ep.contains("body_mapping") && ep["body_mapping"].isObject()) { for (auto it = ep["body_mapping"].begin(); it != ep["body_mapping"].end(); ++it) { - rest.body_mapping[it.key()] = it.value().get(); + auto kv = *it; + rest.body_mapping[kv.first] = kv.second.getString(); } } - rest.response_path = ep.value("response_path", ""); + rest.response_path = ep.contains("response_path") ? ep["response_path"].getString() : ""; def.rest_endpoint = std::move(rest); } @@ -304,70 +312,74 @@ inline Result ConfigLoader::parseToolDefinition( if (json.contains("mcp_reference")) { const auto& ref = json["mcp_reference"]; ToolDefinition::MCPReference mcp; - mcp.server_name = ref.value("server_name", ""); - mcp.tool_name = ref.value("tool_name", ""); + 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"].is_array()) { - for (const auto& tag : json["tags"]) { - def.tags.push_back(tag.get()); + 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.value("require_approval", false); + def.require_approval = json.contains("require_approval") ? json["require_approval"].getBool() : false; - return Result::ok(std::move(def)); + return Result(std::move(def)); } inline Result ConfigLoader::loadFromJson(const JsonValue& json) { RegistryConfig config; - config.name = json.value("name", "tool-registry"); - config.base_url = substituteEnvVars(json.value("base_url", "")); + config.name = json.contains("name") ? json["name"].getString() : "tool-registry"; + config.base_url = substituteEnvVars(json.contains("base_url") ? json["base_url"].getString() : ""); // Parse default headers - if (json.contains("default_headers") && json["default_headers"].is_object()) { + if (json.contains("default_headers") && json["default_headers"].isObject()) { for (auto it = json["default_headers"].begin(); it != json["default_headers"].end(); ++it) { - config.default_headers[it.key()] = - substituteEnvVars(it.value().get()); + auto kv = *it; + config.default_headers[kv.first] = substituteEnvVars(kv.second.getString()); } } // Parse auth presets - if (json.contains("auth_presets") && json["auth_presets"].is_object()) { + if (json.contains("auth_presets") && json["auth_presets"].isObject()) { for (auto it = json["auth_presets"].begin(); it != json["auth_presets"].end(); ++it) { - auto auth_result = parseAuthPreset(it.value()); - if (auth_result.isOk()) { - config.auth_presets[it.key()] = auth_result.value(); + 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 - if (json.contains("mcp_servers") && json["mcp_servers"].is_array()) { - for (const auto& server_json : json["mcp_servers"]) { - auto server_result = parseMCPServerDefinition(server_json); - if (server_result.isOk()) { - config.mcp_servers.push_back(std::move(server_result.value())); + if (json.contains("mcp_servers") && json["mcp_servers"].isArray()) { + const auto& servers = json["mcp_servers"]; + for (size_t i = 0; i < servers.size(); ++i) { + auto server_result = parseMCPServerDefinition(servers[i]); + if (mcp::holds_alternative(server_result)) { + config.mcp_servers.push_back(std::move(mcp::get(server_result))); } } } // Parse tools - if (json.contains("tools") && json["tools"].is_array()) { - for (const auto& tool_json : json["tools"]) { - auto tool_result = parseToolDefinition(tool_json); - if (tool_result.isOk()) { - config.tools.push_back(std::move(tool_result.value())); + if (json.contains("tools") && json["tools"].isArray()) { + const auto& tools = json["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::ok(std::move(config)); + return Result(std::move(config)); } inline Result ConfigLoader::loadFromString( @@ -376,7 +388,7 @@ inline Result ConfigLoader::loadFromString( JsonValue json = JsonValue::parse(json_string); return loadFromJson(json); } catch (const std::exception& e) { - return Result::error( + return Result( Error(-1, std::string("JSON parse error: ") + e.what())); } } diff --git a/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h index fed9ad20..c129f97a 100644 --- a/include/gopher/orch/agent/rest_tool_adapter.h +++ b/include/gopher/orch/agent/rest_tool_adapter.h @@ -64,12 +64,12 @@ inline JsonValue extractJsonPath(const JsonValue& json, const std::string& path) } int index = std::stoi(index_str); - if (!current.is_array() || index >= static_cast(current.size())) { + if (!current.isArray() || index >= static_cast(current.size())) { return JsonValue(); } current = current[index]; } else { - if (!current.is_object() || !current.contains(token)) { + if (!current.isObject() || !current.contains(token)) { return JsonValue(); } current = current[token]; @@ -83,13 +83,13 @@ inline JsonValue extractJsonPath(const JsonValue& json, const std::string& path) inline std::string extractJsonPathString(const JsonValue& json, const std::string& path) { JsonValue value = extractJsonPath(json, path); - if (value.is_null()) { + if (value.isNull()) { return ""; } - if (value.is_string()) { - return value.get(); + if (value.isString()) { + return value.getString(); } - return value.dump(); + return value.toString(); } // URL encode string @@ -158,20 +158,20 @@ class RESTToolAdapter { } // Substitute path parameters - for (const auto& [param, json_path] : endpoint.path_params) { - std::string value = extractJsonPathString(input, json_path); - std::regex param_regex("\\{" + param + "\\}"); + 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& [param, json_path] : endpoint.query_params) { - std::string value = substituteEnvVars(extractJsonPathString(input, json_path)); + for (const auto& kv : endpoint.query_params) { + std::string value = substituteEnvVars(extractJsonPathString(input, kv.second)); if (!value.empty()) { url += (has_query ? "&" : "?"); - url += urlEncode(param) + "=" + urlEncode(value); + url += urlEncode(kv.first) + "=" + urlEncode(value); has_query = true; } } @@ -179,8 +179,8 @@ class RESTToolAdapter { // Build headers std::map headers = default_headers_; - for (const auto& [key, value] : endpoint.headers) { - headers[key] = substituteEnvVars(value); + for (const auto& kv : endpoint.headers) { + headers[kv.first] = substituteEnvVars(kv.second); } if (headers.find("Content-Type") == headers.end()) { headers["Content-Type"] = "application/json"; @@ -193,12 +193,12 @@ class RESTToolAdapter { endpoint.method == HttpMethod::PATCH) { if (!endpoint.body_mapping.empty()) { JsonValue body_json = JsonValue::object(); - for (const auto& [key, json_path] : endpoint.body_mapping) { - body_json[key] = extractJsonPath(input, json_path); + for (const auto& kv : endpoint.body_mapping) { + body_json[kv.first] = extractJsonPath(input, kv.second); } - body = body_json.dump(); + body = body_json.toString(); } else { - body = input.dump(); + body = input.toString(); } } @@ -206,14 +206,14 @@ class RESTToolAdapter { http_client_->request( endpoint.method, url, headers, body, dispatcher, [endpoint, callback = std::move(callback)](Result result) { - if (!result.isOk()) { - callback(Result::error(result.error())); + if (!mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); return; } - auto& response = result.value(); + auto& response = mcp::get(result); if (!response.isSuccess()) { - callback(Result::error( + callback(Result( Error(-1, "HTTP " + std::to_string(response.status_code) + ": " + response.body))); return; @@ -237,7 +237,7 @@ class RESTToolAdapter { json = extractJsonPath(json, endpoint.response_path); } - callback(Result::ok(std::move(json))); + callback(Result(std::move(json))); }); } diff --git a/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h index ba72b751..8f144dc8 100644 --- a/include/gopher/orch/agent/tool_registry.h +++ b/include/gopher/orch/agent/tool_registry.h @@ -180,10 +180,10 @@ class ToolRegistry { // List and register tools server->listTools(dispatcher, [this, server](Result> result) { - if (!result.isOk()) return; + if (!mcp::holds_alternative>(result)) return; std::lock_guard lock(mutex_); - for (const auto& info : result.value()) { + for (const auto& info : mcp::get>(result)) { ToolEntry entry; entry.spec = toToolSpec(info); // Use conversion utility entry.server = server; @@ -340,7 +340,7 @@ class ToolRegistry { auto it = tools_.find(name); if (it == tools_.end()) { dispatcher.post([callback = std::move(callback), name]() { - callback(Result::error( + callback(Result( Error(-1, "Tool not found: " + name))); }); return; @@ -426,20 +426,20 @@ class ToolRegistry { // #include "gopher/orch/agent/rest_tool_adapter.h" void loadFromFile(const std::string& path, Dispatcher& dispatcher, - std::function)> callback); + std::function callback); // Load from JSON string void loadFromString(const std::string& json_string, Dispatcher& dispatcher, - std::function)> callback); + std::function callback); // Load from RegistryConfig struct void loadConfig(const RegistryConfig& config, Dispatcher& dispatcher, - std::function)> callback); + std::function callback); // Register a tool from ToolDefinition - Result registerTool(const ToolDefinition& def, + VoidResult registerTool(const ToolDefinition& def, Dispatcher& dispatcher); // ═══════════════════════════════════════════════════════════════════════════ @@ -453,7 +453,7 @@ class ToolRegistry { } // Load environment from .env file - Result loadEnvFile(const std::string& path); + VoidResult loadEnvFile(const std::string& path); // ═══════════════════════════════════════════════════════════════════════════ // MCP SERVER MANAGEMENT (for config loading) @@ -462,7 +462,7 @@ class ToolRegistry { // Add MCP server from definition void addMCPServer(const MCPServerDefinition& def, Dispatcher& dispatcher, - std::function)> callback); + std::function callback); // Get registered MCP server by name ServerPtr getMCPServer(const std::string& name) const { diff --git a/src/gopher/orch/agent/agent.cpp b/src/gopher/orch/agent/agent.cpp index 20f727c7..67d0b1af 100644 --- a/src/gopher/orch/agent/agent.cpp +++ b/src/gopher/orch/agent/agent.cpp @@ -125,7 +125,7 @@ void ReActAgent::run(const std::string& query, // Check if already running if (impl_->state.status == AgentStatus::RUNNING) { dispatcher.post([callback = std::move(callback)]() { - callback(Result::error( + callback(Result( Error(AgentError::UNKNOWN, "Agent is already running"))); }); return; @@ -134,7 +134,7 @@ void ReActAgent::run(const std::string& query, // Check provider if (!impl_->provider) { dispatcher.post([callback = std::move(callback)]() { - callback(Result::error( + callback(Result( Error(AgentError::NO_PROVIDER, "No LLM provider configured"))); }); return; @@ -261,8 +261,8 @@ void ReActAgent::callLLM(Dispatcher& dispatcher) { impl_->provider->chat( messages, tools, config, dispatcher, [this, &dispatcher, start_time](Result result) { - if (!result.isOk()) { - impl_->state.error = result.error(); + if (!mcp::holds_alternative(result)) { + impl_->state.error = mcp::get(result); completeRun(AgentStatus::FAILED, dispatcher); return; } @@ -270,15 +270,17 @@ void ReActAgent::callLLM(Dispatcher& dispatcher) { 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 = result.value().message; - step.llm_usage = result.value().usage; + step.llm_message = response.message; + step.llm_usage = response.usage; step.llm_duration = duration; // Handle response - handleLLMResponse(result.value(), dispatcher); + handleLLMResponse(response, dispatcher); // Record step (will be updated with tool results if needed) impl_->recordStep(step); @@ -353,12 +355,12 @@ void ReActAgent::handleToolResults(const std::vector& calls, exec.input = calls[i].arguments; if (i < results.size()) { - if (results[i].isOk()) { - exec.output = results[i].value(); + if (mcp::holds_alternative(results[i])) { + exec.output = mcp::get(results[i]); exec.success = true; } else { exec.success = false; - exec.error_message = results[i].error().message; + exec.error_message = mcp::get(results[i]).message; } } @@ -371,10 +373,10 @@ void ReActAgent::handleToolResults(const std::vector& calls, std::string result_content; if (i < results.size()) { - if (results[i].isOk()) { - result_content = results[i].value().dump(); + if (mcp::holds_alternative(results[i])) { + result_content = mcp::get(results[i]).toString(); } else { - result_content = "Error: " + results[i].error().message; + result_content = "Error: " + mcp::get(results[i]).message; } } else { result_content = "Error: No result returned"; @@ -400,9 +402,9 @@ void ReActAgent::completeRun(AgentStatus status, Dispatcher& dispatcher) { impl_->completion_callback = nullptr; if (status == AgentStatus::COMPLETED) { - callback(Result::ok(std::move(result))); + callback(Result(std::move(result))); } else { - callback(Result::error( + callback(Result( impl_->state.error.value_or(Error(AgentError::UNKNOWN, "Unknown error")))); } } diff --git a/src/gopher/orch/agent/config_loader.cpp b/src/gopher/orch/agent/config_loader.cpp index 339dc694..7ff495ff 100644 --- a/src/gopher/orch/agent/config_loader.cpp +++ b/src/gopher/orch/agent/config_loader.cpp @@ -9,10 +9,10 @@ namespace gopher { namespace orch { namespace agent { -Result ConfigLoader::loadEnvFile(const std::string& path) { +VoidResult ConfigLoader::loadEnvFile(const std::string& path) { std::ifstream file(path); if (!file.is_open()) { - return Result::error(Error(-1, "Cannot open .env file: " + path)); + return VoidResult(Error(-1, "Cannot open .env file: " + path)); } std::string line; @@ -50,13 +50,13 @@ Result ConfigLoader::loadEnvFile(const std::string& path) { } } - return Result::ok(); + return VoidResult(nullptr); } Result ConfigLoader::loadFromFile(const std::string& path) { std::ifstream file(path); if (!file.is_open()) { - return Result::error( + return Result( Error(-1, "Cannot open config file: " + path)); } diff --git a/src/gopher/orch/agent/tool_registry.cpp b/src/gopher/orch/agent/tool_registry.cpp index 2fad2848..59d135df 100644 --- a/src/gopher/orch/agent/tool_registry.cpp +++ b/src/gopher/orch/agent/tool_registry.cpp @@ -19,7 +19,7 @@ namespace agent { void ToolRegistry::loadFromFile(const std::string& path, Dispatcher& dispatcher, - std::function)> callback) { + std::function callback) { ConfigLoader loader; // Copy env vars to loader @@ -31,19 +31,19 @@ void ToolRegistry::loadFromFile(const std::string& path, } auto result = loader.loadFromFile(path); - if (!result.isOk()) { - dispatcher.post([callback = std::move(callback), err = result.error()]() { - callback(Result::error(err)); + if (!mcp::holds_alternative(result)) { + dispatcher.post([callback = std::move(callback), err = mcp::get(result)]() { + callback(VoidResult(err)); }); return; } - loadConfig(result.value(), dispatcher, std::move(callback)); + loadConfig(mcp::get(result), dispatcher, std::move(callback)); } void ToolRegistry::loadFromString(const std::string& json_string, Dispatcher& dispatcher, - std::function)> callback) { + std::function callback) { ConfigLoader loader; { @@ -54,19 +54,19 @@ void ToolRegistry::loadFromString(const std::string& json_string, } auto result = loader.loadFromString(json_string); - if (!result.isOk()) { - dispatcher.post([callback = std::move(callback), err = result.error()]() { - callback(Result::error(err)); + if (!mcp::holds_alternative(result)) { + dispatcher.post([callback = std::move(callback), err = mcp::get(result)]() { + callback(VoidResult(err)); }); return; } - loadConfig(result.value(), dispatcher, std::move(callback)); + loadConfig(mcp::get(result), dispatcher, std::move(callback)); } void ToolRegistry::loadConfig(const RegistryConfig& config, Dispatcher& dispatcher, - std::function)> callback) { + std::function callback) { // Track pending MCP server connections auto pending = std::make_shared>(config.mcp_servers.size()); auto errors = std::make_shared>(); @@ -78,9 +78,9 @@ void ToolRegistry::loadConfig(const RegistryConfig& config, // Register tools after all MCP servers connected for (const auto& tool_def : config_copy->tools) { auto result = self->registerTool(tool_def, dispatcher); - if (!result.isOk()) { + if (!mcp::holds_alternative(result)) { errors->push_back("Tool " + tool_def.name + ": " + - result.error().message); + mcp::get(result).message); } } @@ -89,9 +89,9 @@ void ToolRegistry::loadConfig(const RegistryConfig& config, for (const auto& e : *errors) { error_msg += "\n - " + e; } - callback(Result::error(Error(-1, error_msg))); + callback(VoidResult(Error(-1, error_msg))); } else { - callback(Result::ok()); + callback(VoidResult(nullptr)); } }; @@ -105,10 +105,10 @@ void ToolRegistry::loadConfig(const RegistryConfig& config, addMCPServer( server_def, dispatcher, [pending, errors, on_all_connected, name = server_def.name]( - Result result) mutable { - if (!result.isOk()) { + VoidResult result) mutable { + if (!mcp::holds_alternative(result)) { errors->push_back("MCP server " + name + ": " + - result.error().message); + mcp::get(result).message); } if (--(*pending) == 0) { @@ -118,8 +118,8 @@ void ToolRegistry::loadConfig(const RegistryConfig& config, } } -Result ToolRegistry::registerTool(const ToolDefinition& def, - Dispatcher& dispatcher) { +VoidResult ToolRegistry::registerTool(const ToolDefinition& def, + Dispatcher& dispatcher) { // Create ToolEntry from definition ToolEntry entry; entry.spec = def.toToolSpec(); @@ -142,8 +142,7 @@ Result ToolRegistry::registerTool(const ToolDefinition& def, entry.function = adapter->createToolFunction(def); if (!entry.function) { - return Result::error( - Error(-1, "Failed to create REST tool: " + def.name)); + return VoidResult(Error(-1, "Failed to create REST tool: " + def.name)); } } else if (def.mcp_reference) { // MCP reference - proxy to MCP server @@ -154,12 +153,10 @@ Result ToolRegistry::registerTool(const ToolDefinition& def, entry.server = server; entry.original_name = ref.tool_name; } else { - return Result::error( - Error(-1, "MCP server not found: " + ref.server_name)); + return VoidResult(Error(-1, "MCP server not found: " + ref.server_name)); } } else { - return Result::error( - Error(-1, "Tool has no handler, REST endpoint, or MCP reference: " + + return VoidResult(Error(-1, "Tool has no handler, REST endpoint, or MCP reference: " + def.name)); } @@ -169,13 +166,13 @@ Result ToolRegistry::registerTool(const ToolDefinition& def, tools_[def.name] = std::move(entry); } - return Result::ok(); + return VoidResult(nullptr); } -Result ToolRegistry::loadEnvFile(const std::string& path) { +VoidResult ToolRegistry::loadEnvFile(const std::string& path) { ConfigLoader loader; auto result = loader.loadEnvFile(path); - if (!result.isOk()) { + if (!mcp::holds_alternative(result)) { return result; } @@ -183,7 +180,7 @@ Result ToolRegistry::loadEnvFile(const std::string& path) { // We need to read the file directly here std::ifstream file(path); if (!file.is_open()) { - return Result::error(Error(-1, "Cannot open .env file: " + path)); + return VoidResult(Error(-1, "Cannot open .env file: " + path)); } std::string line; @@ -215,7 +212,7 @@ Result ToolRegistry::loadEnvFile(const std::string& path) { } } - return Result::ok(); + return VoidResult(nullptr); } // ═══════════════════════════════════════════════════════════════════════════ @@ -224,7 +221,7 @@ Result ToolRegistry::loadEnvFile(const std::string& path) { void ToolRegistry::addMCPServer(const MCPServerDefinition& def, Dispatcher& dispatcher, - std::function)> callback) { + std::function callback) { #ifdef GOPHER_ORCH_WITH_MCP using namespace gopher::orch::server; @@ -239,7 +236,7 @@ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, case MCPServerDefinition::TransportType::STDIO: { if (!def.stdio_config) { dispatcher.post([callback = std::move(callback)]() { - callback(Result::error(Error(-1, "STDIO config missing"))); + callback(VoidResult(Error(-1, "STDIO config missing"))); }); return; } @@ -254,7 +251,7 @@ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, case MCPServerDefinition::TransportType::HTTP_SSE: { if (!def.http_sse_config) { dispatcher.post([callback = std::move(callback)]() { - callback(Result::error(Error(-1, "HTTP-SSE config missing"))); + callback(VoidResult(Error(-1, "HTTP-SSE config missing"))); }); return; } @@ -268,7 +265,7 @@ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, case MCPServerDefinition::TransportType::WEBSOCKET: { if (!def.websocket_config) { dispatcher.post([callback = std::move(callback)]() { - callback(Result::error(Error(-1, "WebSocket config missing"))); + callback(VoidResult(Error(-1, "WebSocket config missing"))); }); return; } @@ -285,12 +282,12 @@ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, config, dispatcher, [this, name = def.name, callback = std::move(callback)]( Result result) { - if (!result.isOk()) { - callback(Result::error(result.error())); + if (!mcp::holds_alternative(result)) { + callback(VoidResult(mcp::get(result))); return; } - auto server = result.value(); + auto server = mcp::get(result); // Store in registry { @@ -299,12 +296,12 @@ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, servers_.push_back(server); } - callback(Result::ok()); + callback(VoidResult(nullptr)); }); #else // MCP not available dispatcher.post([callback = std::move(callback)]() { - callback(Result::error( + callback(VoidResult( Error(-1, "MCP support not compiled (GOPHER_ORCH_WITH_MCP not defined)"))); }); #endif diff --git a/src/gopher/orch/llm/anthropic_provider.cpp b/src/gopher/orch/llm/anthropic_provider.cpp index b516004d..fdb4a8d6 100644 --- a/src/gopher/orch/llm/anthropic_provider.cpp +++ b/src/gopher/orch/llm/anthropic_provider.cpp @@ -88,7 +88,7 @@ void AnthropicProvider::chat(const std::vector& messages, Dispatcher& dispatcher, ChatCallback callback) { auto request = buildRequest(messages, tools, config, false); - auto request_body = request.dump(); + auto request_body = request.toString(); auto url = impl_->messagesEndpoint(); auto headers = impl_->headers(); @@ -96,19 +96,19 @@ void AnthropicProvider::chat(const std::vector& messages, impl_->http_client->request( HttpMethod::POST, url, headers, request_body, dispatcher, [this, callback = std::move(callback)](Result result) { - if (!result.isOk()) { - callback(Result::error(result.error())); + if (!mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); return; } - auto& response = result.value(); + 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"].get(); + error_msg = error_json["error"]["message"].getString(); } } catch (...) { error_msg += ": " + response.body; @@ -123,7 +123,7 @@ void AnthropicProvider::chat(const std::vector& messages, error_code = LLMError::SERVICE_UNAVAILABLE; } - callback(Result::error(Error(error_code, error_msg))); + callback(Result(Error(error_code, error_msg))); return; } @@ -132,7 +132,7 @@ void AnthropicProvider::chat(const std::vector& messages, auto parsed = parseResponse(response_json); callback(std::move(parsed)); } catch (const std::exception& e) { - callback(Result::error( + callback(Result( Error(LLMError::PARSE_ERROR, std::string("Failed to parse response: ") + e.what()))); } @@ -293,8 +293,8 @@ Result AnthropicProvider::parseResponse(const JsonValue& response) try { // Parse stop reason - if (response.contains("stop_reason") && !response["stop_reason"].is_null()) { - std::string stop_reason = response["stop_reason"].get(); + 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"; @@ -310,23 +310,25 @@ Result AnthropicProvider::parseResponse(const JsonValue& response) result.message.role = Role::ASSISTANT; // Parse content array - if (response.contains("content") && response["content"].is_array()) { + if (response.contains("content") && response["content"].isArray()) { std::string text_content; std::vector tool_calls; - for (const auto& block : response["content"]) { - std::string block_type = block.value("type", ""); + 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"].get(); + text_content += block["text"].getString(); } else if (block_type == "tool_use") { ToolCall tc; - tc.id = block["id"].get(); - tc.name = block["name"].get(); + tc.id = block["id"].getString(); + tc.name = block["name"].getString(); tc.arguments = block["input"]; tool_calls.push_back(std::move(tc)); } @@ -342,16 +344,16 @@ Result AnthropicProvider::parseResponse(const JsonValue& response) if (response.contains("usage")) { const auto& usage = response["usage"]; Usage u; - u.prompt_tokens = usage.value("input_tokens", 0); - u.completion_tokens = usage.value("output_tokens", 0); + 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::ok(std::move(result)); + return Result(std::move(result)); } catch (const std::exception& e) { - return Result::error( + return Result( Error(LLMError::PARSE_ERROR, std::string("Parse error: ") + e.what())); } } diff --git a/src/gopher/orch/llm/openai_provider.cpp b/src/gopher/orch/llm/openai_provider.cpp index 2c5ab939..98156b10 100644 --- a/src/gopher/orch/llm/openai_provider.cpp +++ b/src/gopher/orch/llm/openai_provider.cpp @@ -91,7 +91,7 @@ void OpenAIProvider::chat(const std::vector& messages, ChatCallback callback) { // Build request auto request = buildRequest(messages, tools, config, false); - auto request_body = request.dump(); + auto request_body = request.toString(); auto url = impl_->chatEndpoint(); auto headers = impl_->headers(); @@ -100,12 +100,12 @@ void OpenAIProvider::chat(const std::vector& messages, impl_->http_client->request( HttpMethod::POST, url, headers, request_body, dispatcher, [this, callback = std::move(callback)](Result result) { - if (!result.isOk()) { - callback(Result::error(result.error())); + if (!mcp::holds_alternative(result)) { + callback(Result(mcp::get(result))); return; } - auto& response = result.value(); + auto& response = mcp::get(result); if (!response.isSuccess()) { // Parse error response std::string error_msg = "HTTP " + std::to_string(response.status_code); @@ -113,7 +113,7 @@ void OpenAIProvider::chat(const std::vector& messages, auto error_json = JsonValue::parse(response.body); if (error_json.contains("error") && error_json["error"].contains("message")) { - error_msg = error_json["error"]["message"].get(); + error_msg = error_json["error"]["message"].getString(); } } catch (...) { error_msg += ": " + response.body; @@ -128,7 +128,7 @@ void OpenAIProvider::chat(const std::vector& messages, error_code = LLMError::SERVICE_UNAVAILABLE; } - callback(Result::error(Error(error_code, error_msg))); + callback(Result(Error(error_code, error_msg))); return; } @@ -138,7 +138,7 @@ void OpenAIProvider::chat(const std::vector& messages, auto parsed = parseResponse(response_json); callback(std::move(parsed)); } catch (const std::exception& e) { - callback(Result::error( + callback(Result( Error(LLMError::PARSE_ERROR, std::string("Failed to parse response: ") + e.what()))); } }); @@ -218,15 +218,15 @@ Result OpenAIProvider::parseResponse(const JsonValue& response) con try { // Get the first choice if (!response.contains("choices") || response["choices"].empty()) { - return Result::error( + 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"].is_null()) { - result.finish_reason = choice["finish_reason"].get(); + if (choice.contains("finish_reason") && !choice["finish_reason"].isNull()) { + result.finish_reason = choice["finish_reason"].getString(); } // Parse message @@ -235,27 +235,28 @@ Result OpenAIProvider::parseResponse(const JsonValue& response) con // Role if (msg.contains("role")) { - result.message.role = parseRole(msg["role"].get()); + result.message.role = parseRole(msg["role"].getString()); } else { result.message.role = Role::ASSISTANT; } // Content - if (msg.contains("content") && !msg["content"].is_null()) { - result.message.content = msg["content"].get(); + if (msg.contains("content") && !msg["content"].isNull()) { + result.message.content = msg["content"].getString(); } // Tool calls - if (msg.contains("tool_calls") && !msg["tool_calls"].is_null()) { + if (msg.contains("tool_calls") && !msg["tool_calls"].isNull()) { std::vector tool_calls; - for (const auto& tc : msg["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"].get(); + call.id = tc["id"].getString(); if (tc.contains("function")) { - call.name = tc["function"]["name"].get(); + call.name = tc["function"]["name"].getString(); if (tc["function"].contains("arguments")) { - std::string args_str = tc["function"]["arguments"].get(); + std::string args_str = tc["function"]["arguments"].getString(); try { call.arguments = JsonValue::parse(args_str); } catch (...) { @@ -275,16 +276,16 @@ Result OpenAIProvider::parseResponse(const JsonValue& response) con if (response.contains("usage")) { const auto& usage = response["usage"]; Usage u; - u.prompt_tokens = usage.value("prompt_tokens", 0); - u.completion_tokens = usage.value("completion_tokens", 0); - u.total_tokens = usage.value("total_tokens", 0); + 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::ok(std::move(result)); + return Result(std::move(result)); } catch (const std::exception& e) { - return Result::error( + return Result( Error(LLMError::PARSE_ERROR, std::string("Parse error: ") + e.what())); } } @@ -319,7 +320,7 @@ JsonValue OpenAIProvider::messageToJson(const Message& msg) const { JsonValue func = JsonValue::object(); func["name"] = tc.name; - func["arguments"] = tc.arguments.dump(); + func["arguments"] = tc.arguments.toString(); call["function"] = func; tool_calls.push_back(call); @@ -347,7 +348,7 @@ Result OpenAIProvider::parseStreamChunk(const std::string& data) co // SSE data parsing would go here // For now, return empty chunk StreamChunk chunk; - return Result::ok(std::move(chunk)); + return Result(std::move(chunk)); } // ═══════════════════════════════════════════════════════════════════════════ From 5dfe89750410149d5e04bcb59f165a9ccc227bfb Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 18:12:47 +0530 Subject: [PATCH 117/197] Add mock implementations for LLM provider and HTTP client testing (#24) --- tests/gopher/orch/mock_http_client.h | 230 +++++++++++++++++++++++++ tests/gopher/orch/mock_llm_provider.h | 239 ++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 tests/gopher/orch/mock_http_client.h create mode 100644 tests/gopher/orch/mock_llm_provider.h diff --git a/tests/gopher/orch/mock_http_client.h b/tests/gopher/orch/mock_http_client.h new file mode 100644 index 00000000..1c5180db --- /dev/null +++ b/tests/gopher/orch/mock_http_client.h @@ -0,0 +1,230 @@ +// 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/tests/gopher/orch/mock_llm_provider.h b/tests/gopher/orch/mock_llm_provider.h new file mode 100644 index 00000000..c9ff8201 --- /dev/null +++ b/tests/gopher/orch/mock_llm_provider.h @@ -0,0 +1,239 @@ +// 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 From f69671ceaaa96a8c74a696c1ef638216f5b766f9 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 18:12:58 +0530 Subject: [PATCH 118/197] Add unit tests for LLM provider and types (#24) --- tests/gopher/orch/llm_provider_test.cc | 284 +++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 tests/gopher/orch/llm_provider_test.cc diff --git a/tests/gopher/orch/llm_provider_test.cc b/tests/gopher/orch/llm_provider_test.cc new file mode 100644 index 00000000..630f536a --- /dev/null +++ b/tests/gopher/orch/llm_provider_test.cc @@ -0,0 +1,284 @@ +// Unit tests for LLM Providers (OpenAI, Anthropic) + +#include "orch_test_fixture.h" +#include "mock_http_client.h" +#include "mock_llm_provider.h" + +#include "gopher/orch/llm/openai_provider.h" +#include "gopher/orch/llm/anthropic_provider.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"); +} From ede6e06c682ad21857af6f674c2297e85d2b6637 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 18:13:09 +0530 Subject: [PATCH 119/197] Add unit tests for ReActAgent and fix step recording order (#24) --- src/gopher/orch/agent/agent.cpp | 8 +- tests/gopher/orch/agent_test.cc | 469 ++++++++++++++++++++++++++++++++ 2 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 tests/gopher/orch/agent_test.cc diff --git a/src/gopher/orch/agent/agent.cpp b/src/gopher/orch/agent/agent.cpp index 67d0b1af..88534b42 100644 --- a/src/gopher/orch/agent/agent.cpp +++ b/src/gopher/orch/agent/agent.cpp @@ -279,11 +279,11 @@ void ReActAgent::callLLM(Dispatcher& dispatcher) { step.llm_usage = response.usage; step.llm_duration = duration; - // Handle response - handleLLMResponse(response, dispatcher); - - // Record step (will be updated with tool results if needed) + // 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); }); } diff --git a/tests/gopher/orch/agent_test.cc b/tests/gopher/orch/agent_test.cc new file mode 100644 index 00000000..36b85c61 --- /dev/null +++ b/tests/gopher/orch/agent_test.cc @@ -0,0 +1,469 @@ +// Unit tests for ReActAgent + +#include "orch_test_fixture.h" +#include "mock_llm_provider.h" + +#include "gopher/orch/agent/agent.h" +#include "gopher/orch/agent/agent_types.h" +#include "gopher/orch/agent/tool_registry.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); +} From 9e2299f73638f9f8d74821cbf8a2d611b7164ea3 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 18:13:20 +0530 Subject: [PATCH 120/197] Add unit tests for ToolRegistry and ConfigLoader (#24) --- tests/gopher/orch/tool_registry_test.cc | 715 ++++++++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 tests/gopher/orch/tool_registry_test.cc diff --git a/tests/gopher/orch/tool_registry_test.cc b/tests/gopher/orch/tool_registry_test.cc new file mode 100644 index 00000000..6431ef69 --- /dev/null +++ b/tests/gopher/orch/tool_registry_test.cc @@ -0,0 +1,715 @@ +// Unit tests for ToolRegistry + +#include "orch_test_fixture.h" + +#include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/agent/tool_definition.h" +#include "gopher/orch/agent/config_loader.h" +#include "gopher/orch/server/mock_server.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_; + std::shared_ptr mock_server_; + + void SetUp() override { + OrchTest::SetUp(); + registry_ = makeToolRegistry(); + 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) { + registry_->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 +// ============================================================================= + +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) { + registry_->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) { + registry_->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) { + registry_->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) { + registry_->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; + + registry_->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, ToolListCallback 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, ToolListCallback cb) { + mock_server_->listTools(d, std::move(cb)); + }); + + registry_->addServer(mock_server_, tools); + + auto result = runToCompletion( + [&](Dispatcher& d, JsonCallback cb) { + registry_->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); + + ToolInfo 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) { + registry_->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, ToolInfoToToolSpec) { + ToolInfo 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, ToolSpecToToolInfo) { + ToolSpec spec; + spec.name = "another_tool"; + spec.description = "Another description"; + spec.parameters = JsonValue::object(); + spec.parameters["type"] = "object"; + + ToolInfo info = toToolInfo(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, MCPReference) { + ToolDefinition::MCPReference 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); +} From 1a8ab8687df6ddf626332fe876c294dd1db9edbf Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 18:13:30 +0530 Subject: [PATCH 121/197] Add ToolFunction typedef to rest_tool_adapter.h (#24) --- include/gopher/orch/agent/rest_tool_adapter.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h index c129f97a..24584524 100644 --- a/include/gopher/orch/agent/rest_tool_adapter.h +++ b/include/gopher/orch/agent/rest_tool_adapter.h @@ -10,6 +10,7 @@ // - Response path extraction // - Environment variable substitution +#include #include #include #include @@ -23,6 +24,11 @@ namespace agent { using namespace gopher::orch::server; +// Tool execution function signature (also defined in tool_registry.h) +using ToolFunction = std::function; + // ═══════════════════════════════════════════════════════════════════════════ // JSON PATH UTILITIES // ═══════════════════════════════════════════════════════════════════════════ From 09cb8ccf6e650bfa7774ac17756664936cc453e6 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 18:13:41 +0530 Subject: [PATCH 122/197] Add agent_test target to tests CMakeLists (#24) --- tests/CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 21cccd04..78c88eb4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,13 @@ set(ORCH_FRAMEWORK_TEST_SOURCES gopher/orch/integration_test.cc ) +# LLM, Agent, and ToolRegistry tests +set(ORCH_AGENT_TEST_SOURCES + gopher/orch/llm_provider_test.cc + gopher/orch/agent_test.cc + gopher/orch/tool_registry_test.cc +) + # FFI tests - organized by component set(FFI_TEST_SOURCES gopher/orch/FFI/ffi_types_test.cc @@ -81,6 +88,9 @@ 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") @@ -88,6 +98,7 @@ add_orch_test(ffi_test "${FFI_TEST_SOURCES}" "ffi") add_executable(gopher-orch-tests ${ORCH_CORE_TEST_SOURCES} ${ORCH_FRAMEWORK_TEST_SOURCES} + ${ORCH_AGENT_TEST_SOURCES} ${FFI_TEST_SOURCES} ${TEST_UTIL_SOURCES} ) From 5710a5bb9f4e1d0c1ac2671083d018edc0f701d2 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Wed, 31 Dec 2025 18:32:52 +0530 Subject: [PATCH 123/197] Add reference design document (#24) --- docs/Agent.md | 497 ++++++++++++++++++++++++++++++++++ docs/LLMProvider.md | 331 +++++++++++++++++++++++ docs/ToolRegistry.md | 622 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1450 insertions(+) create mode 100644 docs/Agent.md create mode 100644 docs/LLMProvider.md create mode 100644 docs/ToolRegistry.md diff --git a/docs/Agent.md b/docs/Agent.md new file mode 100644 index 00000000..a7554fc2 --- /dev/null +++ b/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 │ │ ToolRegistry │ │ AgentState │ │ +│ │ │ │ │ │ │ │ +│ │ • chat() │ │ • getToolSpecs()│ │ • messages │ │ +│ │ • toolCalls │ │ • executeTool() │ │ • 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 registry.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/docs/LLMProvider.md b/docs/LLMProvider.md new file mode 100644 index 00000000..283237a3 --- /dev/null +++ b/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/docs/ToolRegistry.md b/docs/ToolRegistry.md new file mode 100644 index 00000000..73c353c7 --- /dev/null +++ b/docs/ToolRegistry.md @@ -0,0 +1,622 @@ +# ToolRegistry Design Document + +## Overview + +ToolRegistry is a unified tool management system that aggregates tools from multiple sources (local functions, MCP servers, REST endpoints) into a single registry. It provides tool specifications for LLMs and handles tool execution with a consistent async interface. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Application / Agent │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ ToolRegistry │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ • addTool() - Register local tools │ │ +│ │ • addServer() - Register MCP server tools │ │ +│ │ • loadFromFile() - Load from JSON config │ │ +│ │ • getToolSpecs() - Get specs for LLM │ │ +│ │ • executeTool() - Execute tool by name │ │ +│ │ • executeToolCalls() - Execute multiple tools (parallel) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ +│ Local Tools │ │ MCP Server │ │ MCP Server │ │ REST Tools │ +│ (Lambda) │ │ (STDIO) │ │ (HTTP) │ │ (Adapter) │ +│ │ │ │ │ │ │ │ +│ • calculator │ │ • weather │ │ • search │ │ • api_call │ +│ • formatter │ │ • geocode │ │ • database │ │ • webhook │ +└──────────────┘ └────────────┘ └────────────┘ └────────────┘ +``` + +## Core Components + +### 1. ToolEntry - Internal Tool 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; } +}; +``` + +### 2. ToolDefinition - Configuration-Driven Definition + +```cpp +struct ToolDefinition { + std::string name; + std::string description; + JsonValue input_schema; + + // Option 1: REST Endpoint + optional rest_endpoint; + + // Option 2: MCP Server Reference + optional mcp_reference; + + // Option 3: Lambda Function + optional handler; + + // Metadata + std::vector tags; + bool require_approval = false; +}; +``` + +### 3. ToolFunction Signature + +```cpp +using ToolFunction = std::function; + +// Synchronous version (wrapped internally) +using SyncToolFunction = std::function(const JsonValue&)>; +``` + +## Tool Registration Flow + +``` +┌────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Source │────▶│ ToolRegistry │────▶│ ToolEntry │ +└────────────┘ └──────────────┘ └─────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + +╔═══════════════════════════════════════════════════════════════════╗ +║ LOCAL TOOL REGISTRATION ║ +╠═══════════════════════════════════════════════════════════════════╣ +║ ║ +║ addTool("name", "desc", schema, lambda) ║ +║ │ ║ +║ ▼ ║ +║ ┌─────────────────────┐ ║ +║ │ Create ToolEntry │ ║ +║ │ • spec.name = name │ ║ +║ │ • spec.desc = desc │ ║ +║ │ • function = lambda │ ║ +║ │ • server = nullptr │ ║ +║ └─────────────────────┘ ║ +║ │ ║ +║ ▼ ║ +║ tools_[name] = entry ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════╝ + +╔═══════════════════════════════════════════════════════════════════╗ +║ MCP SERVER REGISTRATION ║ +╠═══════════════════════════════════════════════════════════════════╣ +║ ║ +║ addServer(server, dispatcher) ║ +║ │ ║ +║ ▼ ║ +║ ┌────────────────────────┐ ║ +║ │ server->listTools() │──────▶ Async tool discovery ║ +║ └────────────────────────┘ ║ +║ │ ║ +║ ▼ ║ +║ For each ToolInfo: ║ +║ ┌─────────────────────────────┐ ║ +║ │ 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 │────▶│ ToolRegistry │────▶│ Tool Handler │────▶│ Result │ +└─────────┘ └──────────────┘ └───────────────┘ └──────────┘ + │ │ │ │ + │ executeTool() │ │ │ + │ ─────────────▶ │ │ │ + │ │ Lookup tool │ │ + │ │ ──────────────▶ │ │ + │ │ │ │ + │ │ if entry.isLocal() │ │ + │ │ ┌─────────────────────────────────┐ │ + │ │ │ entry.function(args, dispatcher,│ │ + │ │ │ callback) │ │ + │ │ └─────────────────────────────────┘ │ + │ │ │ │ + │ │ if entry.isRemote()│ │ + │ │ ┌─────────────────────────────────┐ │ + │ │ │ entry.server->callTool( │ │ + │ │ │ original_name, args, │ │ + │ │ │ config, dispatcher, callback) │ │ + │ │ └─────────────────────────────────┘ │ + │ │ │ │ + │ │ │ Execute tool │ + │ │ │ ────────────────▶ │ + │ │ │ │ + │ ◀────────────────────────────────────────────────────── │ + │ callback(Result) │ +``` + +## Parallel Tool Execution + +``` +┌─────────┐ ┌──────────────┐ +│ Agent │────▶│ ToolRegistry │ +└─────────┘ └──────────────┘ + │ │ + │ executeToolCalls(calls, parallel=true) + │ ─────────────────────────────────────▶ + │ │ + │ │ ┌─────────────────────────────────────────┐ + │ │ │ Create shared state: │ + │ │ │ • results = vector(calls.size())│ + │ │ │ • pending = atomic(calls.size()) │ + │ │ └─────────────────────────────────────────┘ + │ │ + │ │ For each call (parallel): + │ │ ┌────────────────────────────────────────┐ + │ │ │ executeTool(call.name, call.args, ..., │ + │ │ │ [i, results, pending, callback](...) │ + │ │ │ { │ + │ │ │ results[i] = result; │ + │ │ │ if (--pending == 0) { │ + │ │ │ callback(results); │ + │ │ │ } │ + │ │ │ } │ + │ │ │ ) │ + │ │ └────────────────────────────────────────┘ + │ │ + │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ │ │ Tool 1 │ │ Tool 2 │ │ Tool 3 │ + │ │ │ ───────▶│ │ ───────▶│ │ ───────▶│ + │ │ └─────────┘ └─────────┘ └─────────┘ + │ │ │ │ │ + │ │ └────────────┴────────────┘ + │ │ │ + │ │ All complete: pending == 0 + │ │ │ + │ ◀────────────────────────────────┘ + │ callback(vector>) +``` + +## Configuration Loading Flow + +``` +┌────────────┐ ┌──────────────┐ ┌──────────────┐ +│ tools.json │────▶│ ConfigLoader │────▶│ ToolRegistry │ +└────────────┘ └──────────────┘ └──────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ + +┌─────────────────────────────────────────────────────────────────┐ +│ tools.json │ +├─────────────────────────────────────────────────────────────────┤ +│ { │ +│ "name": "my-tools", │ +│ "base_url": "https://api.example.com", │ +│ "auth_presets": { │ +│ "main": { "type": "bearer", "value": "${API_KEY}" } │ +│ }, │ +│ "mcp_servers": [ │ +│ { "name": "weather", "transport": "stdio", │ +│ "command": "weather-server" } │ +│ ], │ +│ "tools": [ │ +│ { "name": "search", "rest_endpoint": {...} }, │ +│ { "name": "calc", "mcp_reference": {...} } │ +│ ] │ +│ } │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Loading Process │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Parse JSON ─────▶ RegistryConfig │ +│ │ +│ 2. Substitute environment variables (${VAR}) │ +│ │ +│ 3. Connect MCP servers (async) │ +│ For each server definition: │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ createServer(def) ─▶ server->connect() ─▶ addServer() │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ 4. Register tools │ +│ For each tool definition: │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ if (rest_endpoint) ─▶ RESTToolAdapter.createTool() │ │ +│ │ if (mcp_reference) ─▶ lookup server, add reference │ │ +│ │ if (handler) ─▶ addTool() with handler │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ 5. Call completion callback │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Example Usage + +### Adding Local Tools + +```cpp +#include "gopher/orch/agent/tool_registry.h" + +using namespace gopher::orch::agent; +using namespace gopher::orch::core; + +auto registry = makeToolRegistry(); + +// Async tool with lambda +JsonValue calcSchema = JsonValue::object(); +calcSchema["type"] = "object"; +JsonValue props = JsonValue::object(); +JsonValue aParam = JsonValue::object(); +aParam["type"] = "number"; +JsonValue bParam = JsonValue::object(); +bParam["type"] = "number"; +props["a"] = aParam; +props["b"] = bParam; +calcSchema["properties"] = props; +calcSchema["required"] = JsonValue::array({"a", "b"}); + +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"); // Aliased as "forecast" + +// Or provide tool list directly (sync) +std::vector tools = { + ToolInfo{"get_weather", "Get current weather", weatherSchema}, + ToolInfo{"get_forecast", "Get weather forecast", forecastSchema} +}; +registry->addServer(weatherServer, tools); +``` + +### 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; + } + }); + +// Or from JSON string +std::string config = R"({ + "name": "my-registry", + "tools": [ + { + "name": "search", + "description": "Search the web", + "rest_endpoint": { + "method": "GET", + "url": "https://api.search.com/v1/search", + "query_params": { "q": "$.query", "limit": "$.limit" }, + "response_path": "$.results" + }, + "input_schema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer" } + }, + "required": ["query"] + } + } + ] +})"; + +registry->loadFromString(config, dispatcher, callback); +``` + +### Executing Tools + +```cpp +// Execute single tool +JsonValue args = JsonValue::object(); +args["a"] = 10; +args["b"] = 20; + +registry->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"; + +registry->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) +}; + +registry->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 +registry->addSyncTool("calculator", "Perform math", mathSchema, + [](const JsonValue& args) -> Result { + // Implementation... + }); + +// Create agent with registry +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; + } + }); +``` + +## 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}" + }, + "secondary": { + "type": "api_key", + "value": "${SECONDARY_KEY}", + "header": "X-API-Key" + } + }, + + "mcp_servers": [ + { + "name": "weather", + "transport": "stdio", + "command": "/usr/local/bin/weather-server", + "args": ["--format", "json"], + "env": { + "API_KEY": "${WEATHER_API_KEY}" + } + }, + { + "name": "database", + "transport": "http_sse", + "url": "https://mcp.example.com/database", + "headers": { + "Authorization": "Bearer ${DB_TOKEN}" + } + } + ], + + "tools": [ + { + "name": "search_web", + "description": "Search the web for information", + "input_schema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query" }, + "limit": { "type": "integer", "default": 10 } + }, + "required": ["query"] + }, + "rest_endpoint": { + "method": "GET", + "url": "${BASE_URL}/search", + "query_params": { + "q": "$.query", + "max_results": "$.limit" + }, + "headers": { + "Authorization": "Bearer ${SEARCH_API_KEY}" + }, + "response_path": "$.results" + }, + "tags": ["search", "web"], + "require_approval": false + }, + { + "name": "get_weather", + "description": "Get weather from MCP server", + "mcp_reference": { + "server_name": "weather", + "tool_name": "current_weather" + } + } + ] +} +``` + +## Environment Variable Substitution + +```cpp +// Set environment variables programmatically +registry->setEnv("API_KEY", "sk-secret-key"); +registry->setEnv("BASE_URL", "https://api.example.com"); + +// Load from .env file +registry->loadEnvFile(".env"); + +// Variables are substituted during config loading +// ${API_KEY} in config becomes "sk-secret-key" +``` + +## Thread Safety + +- Configuration methods (`addTool`, `addServer`) should be called before use +- `executeTool` and `getToolSpecs` are thread-safe after configuration +- All callbacks are invoked in the dispatcher thread context +- Internal state is protected by mutex + +## Error Handling + +```cpp +registry->executeTool("nonexistent", args, dispatcher, + [](Result result) { + if (!mcp::holds_alternative(result)) { + auto& error = mcp::get(result); + + // Error codes: + // -1: Tool not found + // -2: Invalid arguments + // -3: Execution failed + // -4: Timeout + + std::cerr << "Error " << error.code << ": " + << error.message << std::endl; + } + }); +``` + +## REST Tool Adapter + +For REST endpoint tools, the RESTToolAdapter handles: + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ RESTToolAdapter │ +├────────────────────────────────────────────────────────────────────┤ +│ │ +│ Input: Tool arguments (JsonValue) │ +│ │ +│ 1. URL Construction │ +│ • Substitute environment variables: ${API_KEY} │ +│ • Replace path parameters: /users/{id} -> /users/123 │ +│ • Build query string: ?q=search&limit=10 │ +│ │ +│ 2. Header Assembly │ +│ • Default headers + endpoint-specific headers │ +│ • Authentication header injection │ +│ │ +│ 3. Body Mapping (POST/PUT/PATCH) │ +│ • Map input fields to request body via JSONPath │ +│ • body_mapping: {"title": "$.title", "content": "$.body"} │ +│ │ +│ 4. Response Processing │ +│ • Parse JSON response │ +│ • Extract via response_path: $.data.results │ +│ • Return extracted value or full response │ +│ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +## Best Practices + +1. **Register tools before starting agent** - Tool discovery is async +2. **Use meaningful tool names** - LLMs use names to decide which tool to call +3. **Provide clear descriptions** - Help LLM understand when to use each tool +4. **Define precise schemas** - Reduce invalid argument errors +5. **Handle errors gracefully** - Tool failures are passed to LLM for recovery +6. **Use prefixed names** for MCP tools to avoid conflicts (`server:tool`) +7. **Set appropriate timeouts** for REST endpoints From 1d3a1da53a007ae409f767735fc6a2e121f145a0 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:18:45 +0530 Subject: [PATCH 124/197] Add new tool executor class (#24) --- include/gopher/orch/agent/tool_executor.h | 144 ++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 include/gopher/orch/agent/tool_executor.h diff --git a/include/gopher/orch/agent/tool_executor.h b/include/gopher/orch/agent/tool_executor.h new file mode 100644 index 00000000..8b4cbdc5 --- /dev/null +++ b/include/gopher/orch/agent/tool_executor.h @@ -0,0 +1,144 @@ +#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 { + // Execute on remote server using original name + 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 From e6111f39826a1db9750fd55211bfc986566192e4 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:19:28 +0530 Subject: [PATCH 125/197] Remove tool executor function from tool registry class (#24) --- include/gopher/orch/agent/tool_registry.h | 105 +++------------------- 1 file changed, 13 insertions(+), 92 deletions(-) diff --git a/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h index 8f144dc8..f6218234 100644 --- a/include/gopher/orch/agent/tool_registry.h +++ b/include/gopher/orch/agent/tool_registry.h @@ -1,37 +1,34 @@ #pragma once -// ToolRegistry - Unified tool management for agents +// ToolRegistry - Tool repository for agents // -// Manages tools from multiple sources: +// Stores and retrieves tools from multiple sources: // - Local lambda functions // - MCP servers (via Server interface) // - REST endpoints (via JSON config) // - JSON configuration files // -// Provides tool specs for LLM and executes tool calls. +// This is a pure repository - for execution, use ToolExecutor. // // Usage: -// ToolRegistry registry; +// auto registry = makeToolRegistry(); // // // Option 1: Load from JSON config -// registry.loadFromFile("tools.json", dispatcher, callback); +// registry->loadFromFile("tools.json", dispatcher, callback); // // // Option 2: Add tools programmatically -// registry.addTool("calculator", "Perform calculations", schema, -// [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { -// // Implementation... -// }); +// registry->addTool("calculator", "Perform calculations", schema, handler); // // // Option 3: Add from MCP server -// registry.addServer(mcpServer); +// registry->addServer(mcpServer); // // // Get specs for LLM -// auto specs = registry.getToolSpecs(); +// auto specs = registry->getToolSpecs(); // -// // Execute tool call -// registry.executeTool("calculator", args, dispatcher, callback); +// // For execution, use ToolExecutor: +// auto executor = makeToolExecutor(registry); +// executor->executeTool("calculator", args, dispatcher, callback); -#include #include #include #include @@ -106,11 +103,11 @@ struct ToolEntry { bool isRemote() const { return server != nullptr; } }; -// ToolRegistry - Manages tools from multiple sources +// ToolRegistry - Tool repository for agents // // Thread Safety: // - Configuration methods (addTool, addServer) should be called before use -// - executeTool and getToolSpecs are thread-safe after configuration +// - Read methods (getToolSpecs, getToolEntry) are thread-safe after configuration class ToolRegistry { public: using Ptr = std::shared_ptr; @@ -324,82 +321,6 @@ class ToolRegistry { return tools_.size(); } - // ═══════════════════════════════════════════════════════════════════════════ - // TOOL EXECUTION - // ═══════════════════════════════════════════════════════════════════════════ - - // Execute a tool by name - void executeTool(const std::string& name, - const JsonValue& arguments, - Dispatcher& dispatcher, - JsonCallback callback) { - ToolEntry entry; - - { - std::lock_guard lock(mutex_); - auto it = tools_.find(name); - if (it == tools_.end()) { - dispatcher.post([callback = std::move(callback), name]() { - callback(Result( - Error(-1, "Tool not found: " + name))); - }); - return; - } - entry = it->second; - } - - if (entry.isLocal()) { - // Execute local function - entry.function(arguments, dispatcher, std::move(callback)); - } else { - // Execute on remote server using original name - 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)); - } - }); - - // If not parallel, wait for completion before next call - // Note: True sequential execution would require callback chaining - // This is a simplified version that still executes in parallel - } - } - // ═══════════════════════════════════════════════════════════════════════════ // MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ From 1ebab99bd250d26a9b9fef8353b2368f6f02c19f Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:20:05 +0530 Subject: [PATCH 126/197] Use tool executor in agent separately (#24) --- include/gopher/orch/agent/agent.h | 1 + src/gopher/orch/agent/agent.cpp | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/include/gopher/orch/agent/agent.h b/include/gopher/orch/agent/agent.h index 369b550b..fdb9261d 100644 --- a/include/gopher/orch/agent/agent.h +++ b/include/gopher/orch/agent/agent.h @@ -26,6 +26,7 @@ #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" diff --git a/src/gopher/orch/agent/agent.cpp b/src/gopher/orch/agent/agent.cpp index 88534b42..7a2cbc05 100644 --- a/src/gopher/orch/agent/agent.cpp +++ b/src/gopher/orch/agent/agent.cpp @@ -19,6 +19,7 @@ class ReActAgent::Impl { public: LLMProviderPtr provider; ToolRegistryPtr tools; + ToolExecutorPtr executor; AgentConfig config; AgentState state; @@ -39,6 +40,7 @@ class ReActAgent::Impl { 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 @@ -317,8 +319,8 @@ void ReActAgent::executeToolCalls(const std::vector& calls, } } - if (!impl_->tools) { - // No tools configured - add error result + 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")); @@ -328,10 +330,10 @@ void ReActAgent::executeToolCalls(const std::vector& calls, return; } - // Execute tools + // Execute tools via executor auto start_time = std::chrono::steady_clock::now(); - impl_->tools->executeToolCalls( + impl_->executor->executeToolCalls( calls, impl_->config.parallel_tool_calls, dispatcher, [this, &dispatcher, calls, start_time]( std::vector> results) { From f86fa8b96a5c91a37d9967981f96d0cc76c19171 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:20:27 +0530 Subject: [PATCH 127/197] Update tool registry unit test (#24) --- tests/gopher/orch/tool_registry_test.cc | 76 +++++++++++++++++++++---- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/tests/gopher/orch/tool_registry_test.cc b/tests/gopher/orch/tool_registry_test.cc index 6431ef69..5616244d 100644 --- a/tests/gopher/orch/tool_registry_test.cc +++ b/tests/gopher/orch/tool_registry_test.cc @@ -1,8 +1,9 @@ -// Unit tests for ToolRegistry +// Unit tests for ToolRegistry and ToolExecutor #include "orch_test_fixture.h" #include "gopher/orch/agent/tool_registry.h" +#include "gopher/orch/agent/tool_executor.h" #include "gopher/orch/agent/tool_definition.h" #include "gopher/orch/agent/config_loader.h" #include "gopher/orch/server/mock_server.h" @@ -18,11 +19,13 @@ using namespace gopher::orch::server; 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"); } @@ -103,7 +106,7 @@ TEST_F(ToolRegistryTest, AddSyncTool) { auto result = runToCompletion( [&](Dispatcher& d, JsonCallback cb) { - registry_->executeTool("sync_calc", JsonValue::object(), d, std::move(cb)); + executor_->executeTool("sync_calc", JsonValue::object(), d, std::move(cb)); }); EXPECT_EQ(result.getInt(), 100); @@ -135,7 +138,7 @@ TEST_F(ToolRegistryTest, AddMultipleTools) { } // ============================================================================= -// Tool Execution Tests +// Tool Execution Tests (via ToolExecutor) // ============================================================================= TEST_F(ToolRegistryTest, ExecuteLocalTool) { @@ -152,7 +155,7 @@ TEST_F(ToolRegistryTest, ExecuteLocalTool) { auto result = runToCompletion( [&](Dispatcher& d, JsonCallback cb) { - registry_->executeTool("echo", input, d, std::move(cb)); + executor_->executeTool("echo", input, d, std::move(cb)); }); EXPECT_TRUE(result.contains("echoed")); @@ -162,7 +165,7 @@ TEST_F(ToolRegistryTest, ExecuteLocalTool) { TEST_F(ToolRegistryTest, ExecuteToolNotFound) { auto result = runToCompletionResult( [&](Dispatcher& d, JsonCallback cb) { - registry_->executeTool("nonexistent", JsonValue::object(), d, std::move(cb)); + executor_->executeTool("nonexistent", JsonValue::object(), d, std::move(cb)); }); EXPECT_TRUE(mcp::holds_alternative(result)); @@ -179,7 +182,7 @@ TEST_F(ToolRegistryTest, ExecuteToolWithError) { auto result = runToCompletionResult( [&](Dispatcher& d, JsonCallback cb) { - registry_->executeTool("failing", JsonValue::object(), d, std::move(cb)); + executor_->executeTool("failing", JsonValue::object(), d, std::move(cb)); }); EXPECT_TRUE(mcp::holds_alternative(result)); @@ -203,7 +206,7 @@ TEST_F(ToolRegistryTest, ExecuteToolCall) { auto result = runToCompletion( [&](Dispatcher& d, JsonCallback cb) { - registry_->executeToolCall(call, d, std::move(cb)); + executor_->executeToolCall(call, d, std::move(cb)); }); EXPECT_EQ(result["greeting"].getString(), "Hello, Alice!"); @@ -239,7 +242,7 @@ TEST_F(ToolRegistryTest, ExecuteMultipleToolCalls) { std::condition_variable cv; bool done = false; - registry_->executeToolCalls( + executor_->executeToolCalls( calls, true, *dispatcher_, [&](std::vector> r) { std::lock_guard lock(mutex); @@ -314,7 +317,7 @@ TEST_F(ToolRegistryTest, ExecuteServerTool) { auto result = runToCompletion( [&](Dispatcher& d, JsonCallback cb) { - registry_->executeTool("remote_calc", JsonValue::object(), d, std::move(cb)); + executor_->executeTool("remote_calc", JsonValue::object(), d, std::move(cb)); }); EXPECT_EQ(result["answer"].getInt(), 42); @@ -337,7 +340,7 @@ TEST_F(ToolRegistryTest, AddServerToolWithAlias) { // Execute via alias auto result = runToCompletion( [&](Dispatcher& d, JsonCallback cb) { - registry_->executeTool("aliased_name", JsonValue::object(), d, std::move(cb)); + executor_->executeTool("aliased_name", JsonValue::object(), d, std::move(cb)); }); EXPECT_EQ(result.getString(), "ok"); @@ -713,3 +716,56 @@ TEST(AuthPresetTest, Types) { 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()); +} From 9f9d99c9e846fd6c7d9faef72c71f18d36ac9c22 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:20:50 +0530 Subject: [PATCH 128/197] Update tool registry document (#24) --- docs/ToolRegistry.md | 412 ++++++++++++++----------------------------- 1 file changed, 130 insertions(+), 282 deletions(-) diff --git a/docs/ToolRegistry.md b/docs/ToolRegistry.md index 73c353c7..5bec93f3 100644 --- a/docs/ToolRegistry.md +++ b/docs/ToolRegistry.md @@ -1,8 +1,13 @@ -# ToolRegistry Design Document +# ToolRegistry & ToolExecutor Design Document ## Overview -ToolRegistry is a unified tool management system that aggregates tools from multiple sources (local functions, MCP servers, REST endpoints) into a single registry. It provides tool specifications for LLMs and handles tool execution with a consistent async interface. +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 @@ -10,79 +15,94 @@ ToolRegistry is a unified tool management system that aggregates tools from mult ┌─────────────────────────────────────────────────────────────────────┐ │ Application / Agent │ └─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ToolRegistry │ -│ ┌───────────────────────────────────────────────────────────────┐ │ -│ │ • addTool() - Register local tools │ │ -│ │ • addServer() - Register MCP server tools │ │ -│ │ • loadFromFile() - Load from JSON config │ │ -│ │ • getToolSpecs() - Get specs for LLM │ │ -│ │ • executeTool() - Execute tool by name │ │ -│ │ • executeToolCalls() - Execute multiple tools (parallel) │ │ -│ └───────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ + │ │ + │ 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) │ -│ │ │ │ │ │ │ │ -│ • calculator │ │ • weather │ │ • search │ │ • api_call │ -│ • formatter │ │ • geocode │ │ • database │ │ • webhook │ └──────────────┘ └────────────┘ └────────────┘ └────────────┘ ``` ## Core Components -### 1. ToolEntry - Internal Tool Representation +### 1. ToolRegistry - Repository ```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; } +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. ToolDefinition - Configuration-Driven Definition +### 2. ToolExecutor - Execution ```cpp -struct ToolDefinition { - std::string name; - std::string description; - JsonValue input_schema; +class ToolExecutor { + public: + explicit ToolExecutor(ToolRegistryPtr registry); - // Option 1: REST Endpoint - optional rest_endpoint; + // Get underlying registry + ToolRegistryPtr registry() const; - // Option 2: MCP Server Reference - optional mcp_reference; + // Execute single tool + void executeTool(name, arguments, dispatcher, callback); - // Option 3: Lambda Function - optional handler; + // Execute ToolCall from LLM + void executeToolCall(call, dispatcher, callback); - // Metadata - std::vector tags; - bool require_approval = false; + // Execute multiple tool calls (parallel) + void executeToolCalls(calls, parallel, dispatcher, callback); }; ``` -### 3. ToolFunction Signature +### 3. ToolEntry - Internal Representation ```cpp -using ToolFunction = std::function; +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 -// Synchronous version (wrapped internally) -using SyncToolFunction = std::function(const JsonValue&)>; + bool isLocal() const { return server == nullptr; } + bool isRemote() const { return server != nullptr; } +}; ``` ## Tool Registration Flow @@ -99,7 +119,7 @@ using SyncToolFunction = std::function(const JsonValue&)>; ║ LOCAL TOOL REGISTRATION ║ ╠═══════════════════════════════════════════════════════════════════╣ ║ ║ -║ addTool("name", "desc", schema, lambda) ║ +║ registry->addTool("name", "desc", schema, lambda) ║ ║ │ ║ ║ ▼ ║ ║ ┌─────────────────────┐ ║ @@ -119,7 +139,7 @@ using SyncToolFunction = std::function(const JsonValue&)>; ║ MCP SERVER REGISTRATION ║ ╠═══════════════════════════════════════════════════════════════════╣ ║ ║ -║ addServer(server, dispatcher) ║ +║ registry->addServer(server, dispatcher) ║ ║ │ ║ ║ ▼ ║ ║ ┌────────────────────────┐ ║ @@ -145,14 +165,17 @@ using SyncToolFunction = std::function(const JsonValue&)>; ## Tool Execution Flow ``` -┌─────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────┐ -│ Agent │────▶│ ToolRegistry │────▶│ Tool Handler │────▶│ Result │ -└─────────┘ └──────────────┘ └───────────────┘ └──────────┘ +┌─────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ +│ Agent │────▶│ ToolExecutor │────▶│ ToolRegistry │────▶│ Result │ +└─────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ │ │ │ │ executeTool() │ │ │ - │ ─────────────▶ │ │ │ - │ │ Lookup tool │ │ - │ │ ──────────────▶ │ │ + │ ───────────────▶│ │ │ + │ │ getToolEntry() │ │ + │ │ ──────────────────▶│ │ + │ │ │ │ + │ │◀──────────────────── │ │ + │ │ ToolEntry │ │ │ │ │ │ │ │ if entry.isLocal() │ │ │ │ ┌─────────────────────────────────┐ │ @@ -167,9 +190,6 @@ using SyncToolFunction = std::function(const JsonValue&)>; │ │ │ config, dispatcher, callback) │ │ │ │ └─────────────────────────────────┘ │ │ │ │ │ - │ │ │ Execute tool │ - │ │ │ ────────────────▶ │ - │ │ │ │ │ ◀────────────────────────────────────────────────────── │ │ callback(Result) │ ``` @@ -178,7 +198,7 @@ using SyncToolFunction = std::function(const JsonValue&)>; ``` ┌─────────┐ ┌──────────────┐ -│ Agent │────▶│ ToolRegistry │ +│ Agent │────▶│ ToolExecutor │ └─────────┘ └──────────────┘ │ │ │ executeToolCalls(calls, parallel=true) @@ -192,15 +212,11 @@ using SyncToolFunction = std::function(const JsonValue&)>; │ │ │ │ For each call (parallel): │ │ ┌────────────────────────────────────────┐ - │ │ │ executeTool(call.name, call.args, ..., │ - │ │ │ [i, results, pending, callback](...) │ - │ │ │ { │ - │ │ │ results[i] = result; │ - │ │ │ if (--pending == 0) { │ - │ │ │ callback(results); │ - │ │ │ } │ - │ │ │ } │ - │ │ │ ) │ + │ │ │ registry->getToolEntry(call.name) │ + │ │ │ execute entry.function or server call │ + │ │ │ on completion: results[i] = result │ + │ │ │ if (--pending == 0) │ + │ │ │ callback(results) │ │ │ └────────────────────────────────────────┘ │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ @@ -216,86 +232,29 @@ using SyncToolFunction = std::function(const JsonValue&)>; │ callback(vector>) ``` -## Configuration Loading Flow - -``` -┌────────────┐ ┌──────────────┐ ┌──────────────┐ -│ tools.json │────▶│ ConfigLoader │────▶│ ToolRegistry │ -└────────────┘ └──────────────┘ └──────────────┘ - │ │ │ - │ │ │ - ▼ ▼ ▼ - -┌─────────────────────────────────────────────────────────────────┐ -│ tools.json │ -├─────────────────────────────────────────────────────────────────┤ -│ { │ -│ "name": "my-tools", │ -│ "base_url": "https://api.example.com", │ -│ "auth_presets": { │ -│ "main": { "type": "bearer", "value": "${API_KEY}" } │ -│ }, │ -│ "mcp_servers": [ │ -│ { "name": "weather", "transport": "stdio", │ -│ "command": "weather-server" } │ -│ ], │ -│ "tools": [ │ -│ { "name": "search", "rest_endpoint": {...} }, │ -│ { "name": "calc", "mcp_reference": {...} } │ -│ ] │ -│ } │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Loading Process │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. Parse JSON ─────▶ RegistryConfig │ -│ │ -│ 2. Substitute environment variables (${VAR}) │ -│ │ -│ 3. Connect MCP servers (async) │ -│ For each server definition: │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ createServer(def) ─▶ server->connect() ─▶ addServer() │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ 4. Register tools │ -│ For each tool definition: │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ if (rest_endpoint) ─▶ RESTToolAdapter.createTool() │ │ -│ │ if (mcp_reference) ─▶ lookup server, add reference │ │ -│ │ if (handler) ─▶ addTool() with handler │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ 5. Call completion callback │ -└─────────────────────────────────────────────────────────────────┘ -``` - ## Example Usage -### Adding Local Tools +### 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"; -JsonValue props = JsonValue::object(); -JsonValue aParam = JsonValue::object(); -aParam["type"] = "number"; -JsonValue bParam = JsonValue::object(); -bParam["type"] = "number"; -props["a"] = aParam; -props["b"] = bParam; -calcSchema["properties"] = props; -calcSchema["required"] = JsonValue::array({"a", "b"}); +// ... schema definition ... registry->addTool("add", "Add two numbers", calcSchema, [](const JsonValue& args, Dispatcher& dispatcher, JsonCallback callback) { @@ -334,7 +293,7 @@ auto weatherServer = createMCPServer("weather", "weather-service", {"--port", "8 registry->addServer(weatherServer, dispatcher); // Or add specific tools by name -registry->addServerTool(weatherServer, "get_forecast", "forecast"); // Aliased as "forecast" +registry->addServerTool(weatherServer, "get_forecast", "forecast"); // Or provide tool list directly (sync) std::vector tools = { @@ -344,57 +303,15 @@ std::vector tools = { registry->addServer(weatherServer, tools); ``` -### 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; - } - }); - -// Or from JSON string -std::string config = R"({ - "name": "my-registry", - "tools": [ - { - "name": "search", - "description": "Search the web", - "rest_endpoint": { - "method": "GET", - "url": "https://api.search.com/v1/search", - "query_params": { "q": "$.query", "limit": "$.limit" }, - "response_path": "$.results" - }, - "input_schema": { - "type": "object", - "properties": { - "query": { "type": "string" }, - "limit": { "type": "integer" } - }, - "required": ["query"] - } - } - ] -})"; - -registry->loadFromString(config, dispatcher, callback); -``` - ### Executing Tools ```cpp -// Execute single tool +// Execute single tool via executor JsonValue args = JsonValue::object(); args["a"] = 10; args["b"] = 20; -registry->executeTool("add", args, dispatcher, +executor->executeTool("add", args, dispatcher, [](Result result) { if (mcp::holds_alternative(result)) { auto& value = mcp::get(result); @@ -406,7 +323,7 @@ registry->executeTool("add", args, dispatcher, ToolCall call("call_123", "search", JsonValue::object()); call.arguments["query"] = "weather in NYC"; -registry->executeToolCall(call, dispatcher, +executor->executeToolCall(call, dispatcher, [](Result result) { // Handle result... }); @@ -417,7 +334,7 @@ std::vector calls = { ToolCall("call_2", "get_time", timeArgs) }; -registry->executeToolCalls(calls, true /* parallel */, dispatcher, +executor->executeToolCalls(calls, true /* parallel */, dispatcher, [](std::vector> results) { for (size_t i = 0; i < results.size(); ++i) { if (mcp::holds_alternative(results[i])) { @@ -438,13 +355,14 @@ registry->executeToolCalls(calls, true /* parallel */, dispatcher, auto provider = OpenAIProvider::create("sk-..."); auto registry = makeToolRegistry(); -// Add tools +// 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 @@ -457,6 +375,21 @@ agent->run("What is 25 * 4?", dispatcher, }); ``` +### 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 @@ -471,11 +404,6 @@ agent->run("What is 25 * 4?", dispatcher, "main_api": { "type": "bearer", "value": "${API_TOKEN}" - }, - "secondary": { - "type": "api_key", - "value": "${SECONDARY_KEY}", - "header": "X-API-Key" } }, @@ -488,14 +416,6 @@ agent->run("What is 25 * 4?", dispatcher, "env": { "API_KEY": "${WEATHER_API_KEY}" } - }, - { - "name": "database", - "transport": "http_sse", - "url": "https://mcp.example.com/database", - "headers": { - "Authorization": "Bearer ${DB_TOKEN}" - } } ], @@ -506,117 +426,45 @@ agent->run("What is 25 * 4?", dispatcher, "input_schema": { "type": "object", "properties": { - "query": { "type": "string", "description": "Search query" }, - "limit": { "type": "integer", "default": 10 } + "query": { "type": "string" } }, "required": ["query"] }, "rest_endpoint": { "method": "GET", "url": "${BASE_URL}/search", - "query_params": { - "q": "$.query", - "max_results": "$.limit" - }, - "headers": { - "Authorization": "Bearer ${SEARCH_API_KEY}" - }, + "query_params": { "q": "$.query" }, "response_path": "$.results" - }, - "tags": ["search", "web"], - "require_approval": false - }, - { - "name": "get_weather", - "description": "Get weather from MCP server", - "mcp_reference": { - "server_name": "weather", - "tool_name": "current_weather" } } ] } ``` -## Environment Variable Substitution - -```cpp -// Set environment variables programmatically -registry->setEnv("API_KEY", "sk-secret-key"); -registry->setEnv("BASE_URL", "https://api.example.com"); - -// Load from .env file -registry->loadEnvFile(".env"); - -// Variables are substituted during config loading -// ${API_KEY} in config becomes "sk-secret-key" -``` - ## Thread Safety -- Configuration methods (`addTool`, `addServer`) should be called before use -- `executeTool` and `getToolSpecs` are thread-safe after configuration -- All callbacks are invoked in the dispatcher thread context -- Internal state is protected by mutex +- **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 -registry->executeTool("nonexistent", args, dispatcher, +executor->executeTool("nonexistent", args, dispatcher, [](Result result) { if (!mcp::holds_alternative(result)) { auto& error = mcp::get(result); - - // Error codes: - // -1: Tool not found - // -2: Invalid arguments - // -3: Execution failed - // -4: Timeout - - std::cerr << "Error " << error.code << ": " - << error.message << std::endl; + std::cerr << "Error: " << error.message << std::endl; } }); ``` -## REST Tool Adapter - -For REST endpoint tools, the RESTToolAdapter handles: - -``` -┌────────────────────────────────────────────────────────────────────┐ -│ RESTToolAdapter │ -├────────────────────────────────────────────────────────────────────┤ -│ │ -│ Input: Tool arguments (JsonValue) │ -│ │ -│ 1. URL Construction │ -│ • Substitute environment variables: ${API_KEY} │ -│ • Replace path parameters: /users/{id} -> /users/123 │ -│ • Build query string: ?q=search&limit=10 │ -│ │ -│ 2. Header Assembly │ -│ • Default headers + endpoint-specific headers │ -│ • Authentication header injection │ -│ │ -│ 3. Body Mapping (POST/PUT/PATCH) │ -│ • Map input fields to request body via JSONPath │ -│ • body_mapping: {"title": "$.title", "content": "$.body"} │ -│ │ -│ 4. Response Processing │ -│ • Parse JSON response │ -│ • Extract via response_path: $.data.results │ -│ • Return extracted value or full response │ -│ │ -└────────────────────────────────────────────────────────────────────┘ -``` - ## Best Practices -1. **Register tools before starting agent** - Tool discovery is async -2. **Use meaningful tool names** - LLMs use names to decide which tool to call -3. **Provide clear descriptions** - Help LLM understand when to use each tool -4. **Define precise schemas** - Reduce invalid argument errors -5. **Handle errors gracefully** - Tool failures are passed to LLM for recovery -6. **Use prefixed names** for MCP tools to avoid conflicts (`server:tool`) -7. **Set appropriate timeouts** for REST endpoints +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`) From ddabfc8da66c551717e4d14e8c40e495fadf3baf Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:46:50 +0530 Subject: [PATCH 129/197] Rename ToolInfo to ServerToolInfo and ToolListCallback to ServerToolListCallback (#24) --- include/gopher/orch/server/server.h | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/include/gopher/orch/server/server.h b/include/gopher/orch/server/server.h index 0cbb1f87..1e7f7a45 100644 --- a/include/gopher/orch/server/server.h +++ b/include/gopher/orch/server/server.h @@ -8,7 +8,7 @@ // Key abstractions: // - Server: Connection to a tool provider // - ServerTool: A tool exposed by the server (implements Runnable) -// - ToolInfo: Metadata about a tool +// - ServerToolInfo: Metadata about a tool from a server #include #include @@ -32,13 +32,13 @@ using ServerPtr = std::shared_ptr; using ServerToolPtr = std::shared_ptr; // Information about a tool exposed by a server -struct ToolInfo { +struct ServerToolInfo { std::string name; std::string description; JsonValue inputSchema; // JSON Schema for tool arguments - ToolInfo() = default; - ToolInfo(const std::string& n, const std::string& desc = "") + ServerToolInfo() = default; + ServerToolInfo(const std::string& n, const std::string& desc = "") : name(n), description(desc), inputSchema(JsonValue::object()) {} }; @@ -53,7 +53,7 @@ enum class ConnectionState { // Callback types using ConnectionCallback = std::function)>; -using ToolListCallback = std::function>)>; +using ServerToolListCallback = std::function>)>; // Server - Abstract interface for protocol-agnostic server access // @@ -89,7 +89,7 @@ class Server : public std::enable_shared_from_this { // List available tools (async) // May return cached list if already connected - virtual void listTools(Dispatcher& dispatcher, ToolListCallback callback) = 0; + virtual void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) = 0; // Get a tool by name as a Runnable // Returns nullptr if tool not found @@ -115,12 +115,12 @@ class Server : public std::enable_shared_from_this { // Wraps a tool call through the server's protocol class ServerTool : public JsonRunnable { public: - ServerTool(ServerPtr server, const ToolInfo& info) + ServerTool(ServerPtr server, const ServerToolInfo& info) : server_(std::move(server)), info_(info) {} std::string name() const override { return info_.name; } - const ToolInfo& info() const { return info_; } + const ServerToolInfo& info() const { return info_; } void invoke(const JsonValue& input, const RunnableConfig& config, @@ -132,7 +132,7 @@ class ServerTool : public JsonRunnable { private: ServerPtr server_; - ToolInfo info_; + ServerToolInfo info_; }; } // namespace server From a6739e9ae0ce37a645490bfb3cd7e67734046f6a Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:04 +0530 Subject: [PATCH 130/197] Update MCPServer to use ServerToolInfo (#24) --- include/gopher/orch/server/mcp_server.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/include/gopher/orch/server/mcp_server.h b/include/gopher/orch/server/mcp_server.h index 0df4018f..2557c1c3 100644 --- a/include/gopher/orch/server/mcp_server.h +++ b/include/gopher/orch/server/mcp_server.h @@ -126,7 +126,7 @@ class MCPServer : public Server { void disconnect(Dispatcher& dispatcher, std::function callback) override; - void listTools(Dispatcher& dispatcher, ToolListCallback callback) override; + void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) override; JsonRunnablePtr tool(const std::string& name) override; @@ -168,8 +168,8 @@ class MCPServer : public Server { // Handle tools listed void onToolsListed(const mcp::ListToolsResult& tools_result); - // Convert MCP Tool to ToolInfo - static ToolInfo toToolInfo(const mcp::Tool& tool); + // Convert MCP Tool to ServerToolInfo + static ServerToolInfo toServerToolInfo(const mcp::Tool& tool); // Convert MCP content to JsonValue static JsonValue contentToJson( @@ -187,7 +187,7 @@ class MCPServer : public Server { mcp::ServerCapabilities capabilities_; // Cached tool information - std::vector tools_; + std::vector tools_; std::map tool_cache_; // Pending callbacks during connection From 970e7aea5ba40c46888da9048ce1b074c2543476 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:08 +0530 Subject: [PATCH 131/197] Update MockServer to use ServerToolInfo (#24) --- include/gopher/orch/server/mock_server.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/include/gopher/orch/server/mock_server.h b/include/gopher/orch/server/mock_server.h index 94b137b6..bfff9407 100644 --- a/include/gopher/orch/server/mock_server.h +++ b/include/gopher/orch/server/mock_server.h @@ -68,8 +68,8 @@ class MockServer : public Server { } } - void listTools(Dispatcher& dispatcher, ToolListCallback callback) override { - std::vector tools; + void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) override { + std::vector tools; { std::lock_guard lock(mutex_); for (const auto& kv : tools_) { @@ -157,12 +157,12 @@ class MockServer : public Server { MockServer& addTool(const std::string& name, const std::string& description = "") { std::lock_guard lock(mutex_); - tools_[name] = ToolInfo(name, description); + tools_[name] = ServerToolInfo(name, description); return *this; } // Add a tool with schema - MockServer& addTool(const ToolInfo& info) { + MockServer& addTool(const ServerToolInfo& info) { std::lock_guard lock(mutex_); tools_[info.name] = info; return *this; @@ -258,7 +258,7 @@ class MockServer : public Server { std::string name_; std::string id_; ConnectionState state_; - std::map tools_; + std::map tools_; std::map configs_; }; From b6440aa85d4e9890a0564bb0b1462871c4ab1e9a Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:13 +0530 Subject: [PATCH 132/197] Update RESTServer to use ServerToolInfo (#24) --- include/gopher/orch/server/rest_server.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/include/gopher/orch/server/rest_server.h b/include/gopher/orch/server/rest_server.h index 66a0089b..69e67b7c 100644 --- a/include/gopher/orch/server/rest_server.h +++ b/include/gopher/orch/server/rest_server.h @@ -93,7 +93,7 @@ inline HttpMethod parseHttpMethod(const std::string& method) { struct RESTToolEndpoint { HttpMethod method = HttpMethod::GET; std::string path; // e.g., "/users/{id}" - ToolInfo info; // Tool metadata + ServerToolInfo info; // Tool metadata // Request body handling bool send_body = @@ -104,7 +104,7 @@ struct RESTToolEndpoint { // use whole response) RESTToolEndpoint() = default; - RESTToolEndpoint(HttpMethod m, const std::string& p, const ToolInfo& i) + RESTToolEndpoint(HttpMethod m, const std::string& p, const ServerToolInfo& i) : method(m), path(p), info(i), @@ -250,7 +250,7 @@ class RESTServer : public Server { void disconnect(Dispatcher& dispatcher, std::function callback) override; - void listTools(Dispatcher& dispatcher, ToolListCallback callback) override; + void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) override; JsonRunnablePtr tool(const std::string& name) override; From 44af26519fdcd5786c1e5a6bc4459f62df906337 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:18 +0530 Subject: [PATCH 133/197] Update ServerComposite to use ServerToolInfo (#24) --- include/gopher/orch/server/server_composite.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/include/gopher/orch/server/server_composite.h b/include/gopher/orch/server/server_composite.h index 5af87de0..baf4ac28 100644 --- a/include/gopher/orch/server/server_composite.h +++ b/include/gopher/orch/server/server_composite.h @@ -109,7 +109,7 @@ class ServerComposite : public std::enable_shared_from_this { std::vector listTools() const; // List all available tools with full info - std::vector listToolInfos() const; + std::vector listToolInfos() const; // Get all registered servers const std::map& servers() const { return servers_; } @@ -320,11 +320,11 @@ inline std::vector ServerComposite::listTools() const { return result; } -inline std::vector ServerComposite::listToolInfos() const { - std::vector result; +inline std::vector ServerComposite::listToolInfos() const { + std::vector result; for (const auto& entry : tool_mappings_) { - ToolInfo info; + ServerToolInfo info; info.name = entry.first; // Try to get description from server From 74d0d019c652eb25aaff2fe0b8621bf5b6c2b137 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:28 +0530 Subject: [PATCH 134/197] Update ToolRegistry to use ServerToolInfo and toServerToolInfo (#24) --- include/gopher/orch/agent/tool_registry.h | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h index f6218234..df62745e 100644 --- a/include/gopher/orch/agent/tool_registry.h +++ b/include/gopher/orch/agent/tool_registry.h @@ -74,8 +74,8 @@ using ToolFunction = std::functionlistTools(dispatcher, [this, server](Result> result) { - if (!mcp::holds_alternative>(result)) return; + server->listTools(dispatcher, [this, server](Result> result) { + if (!mcp::holds_alternative>(result)) return; std::lock_guard lock(mutex_); - for (const auto& info : mcp::get>(result)) { + for (const auto& info : mcp::get>(result)) { ToolEntry entry; entry.spec = toToolSpec(info); // Use conversion utility entry.server = server; @@ -199,7 +199,7 @@ class ToolRegistry { } // Add all tools from a server (sync - provide tool list directly) - void addServer(ServerPtr server, const std::vector& tools) { + void addServer(ServerPtr server, const std::vector& tools) { if (!server) return; std::lock_guard lock(mutex_); @@ -220,9 +220,9 @@ class ToolRegistry { } } - // Add specific tool from a server with ToolInfo + // Add specific tool from a server with ServerToolInfo void addServerTool(ServerPtr server, - const ToolInfo& info, + const ServerToolInfo& info, const std::string& alias = "") { if (!server) return; From 6a43eb2cf690fbbec06b934c8e825eefc7ad66f9 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:31 +0530 Subject: [PATCH 135/197] Update exports to use ServerToolInfo and toServerToolInfo (#24) --- include/gopher/orch/orch.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 1f69882a..9dc5ae25 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -177,8 +177,8 @@ using server::ServerCompositePtr; using server::ServerPtr; using server::ServerTool; using server::ServerToolPtr; -using server::ToolInfo; -using server::ToolListCallback; +using server::ServerToolInfo; +using server::ServerToolListCallback; using server::ToolMapping; // MCP Server and REST Server exports (conditional) @@ -239,8 +239,8 @@ using agent::ToolExecution; using agent::ToolFunction; using agent::ToolRegistry; using agent::ToolRegistryPtr; -using agent::toToolInfo; // Convert ToolSpec -> ToolInfo -using agent::toToolSpec; // Convert ToolInfo -> ToolSpec +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 From e87ccf604bc7fd97a02e83fae629071698b668e5 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:41 +0530 Subject: [PATCH 136/197] Update MCPServer implementation for ServerToolInfo (#24) --- src/gopher/orch/server/mcp_server.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/gopher/orch/server/mcp_server.cpp b/src/gopher/orch/server/mcp_server.cpp index 888e78b8..cad8d2cb 100644 --- a/src/gopher/orch/server/mcp_server.cpp +++ b/src/gopher/orch/server/mcp_server.cpp @@ -271,13 +271,13 @@ void MCPServer::onToolsListed(const mcp::ListToolsResult& tools_result) { tools_.reserve(tools_result.tools.size()); for (const auto& mcp_tool : tools_result.tools) { - tools_.push_back(toToolInfo(mcp_tool)); + tools_.push_back(toServerToolInfo(mcp_tool)); } } -// Convert MCP Tool to ToolInfo -ToolInfo MCPServer::toToolInfo(const mcp::Tool& tool) { - ToolInfo info; +// 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; @@ -358,10 +358,10 @@ void MCPServer::disconnect(Dispatcher& dispatcher, } // List available tools -void MCPServer::listTools(Dispatcher& dispatcher, ToolListCallback callback) { +void MCPServer::listTools(Dispatcher& dispatcher, ServerToolListCallback callback) { if (!this->Server::isConnected()) { dispatcher.post([callback]() { - callback(makeOrchError>(OrchError::NOT_CONNECTED, + callback(makeOrchError>(OrchError::NOT_CONNECTED, "Server is not connected")); }); return; @@ -386,7 +386,7 @@ void MCPServer::listTools(Dispatcher& dispatcher, ToolListCallback callback) { self->onToolsListed(tools_result); callback(makeSuccess(self->tools_)); } catch (const std::exception& e) { - callback(makeOrchError>(OrchError::INTERNAL_ERROR, + callback(makeOrchError>(OrchError::INTERNAL_ERROR, e.what())); } }); @@ -401,7 +401,7 @@ JsonRunnablePtr MCPServer::tool(const std::string& name) { } // Find tool info - ToolInfo info; + ServerToolInfo info; bool found = false; for (const auto& t : tools_) { if (t.name == name) { From 783486ac7c733ac1e4bdb1a0b2c636d96ff8d31c Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:45 +0530 Subject: [PATCH 137/197] Update RESTServer implementation for ServerToolListCallback (#24) --- src/gopher/orch/server/rest_server.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gopher/orch/server/rest_server.cpp b/src/gopher/orch/server/rest_server.cpp index 685f1dd6..a0c061c8 100644 --- a/src/gopher/orch/server/rest_server.cpp +++ b/src/gopher/orch/server/rest_server.cpp @@ -224,8 +224,8 @@ void RESTServer::disconnect(Dispatcher& dispatcher, } } -void RESTServer::listTools(Dispatcher& dispatcher, ToolListCallback callback) { - std::vector tools; +void RESTServer::listTools(Dispatcher& dispatcher, ServerToolListCallback callback) { + std::vector tools; tools.reserve(config_.tools.size()); for (const auto& entry : config_.tools) { From e2d07bd9e37bc1ea7a4bcc481bed5df1e5200e11 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:47:58 +0530 Subject: [PATCH 138/197] Update MockServer tests for ServerToolInfo (#24) --- tests/gopher/orch/mock_server_test.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/gopher/orch/mock_server_test.cc b/tests/gopher/orch/mock_server_test.cc index ccb576a4..ca2d5daf 100644 --- a/tests/gopher/orch/mock_server_test.cc +++ b/tests/gopher/orch/mock_server_test.cc @@ -26,8 +26,8 @@ TEST_F(OrchTest, MockServerBasic) { EXPECT_TRUE(server->isConnected()); // List tools - auto tools = runToCompletion>( - [&](Dispatcher& d, ToolListCallback cb) { + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { server->listTools(d, std::move(cb)); }); From 8ba4877ecd5dd8d75180415e51561ff6f1497a0b Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:48:01 +0530 Subject: [PATCH 139/197] Update RESTServer tests for ServerToolInfo (#24) --- tests/gopher/orch/rest_server_test.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/gopher/orch/rest_server_test.cc b/tests/gopher/orch/rest_server_test.cc index 791362b2..f054fe9e 100644 --- a/tests/gopher/orch/rest_server_test.cc +++ b/tests/gopher/orch/rest_server_test.cc @@ -209,8 +209,8 @@ TEST_F(OrchTest, RESTServerListTools) { server->connect(d, std::move(cb)); }); - auto tools = runToCompletion>( - [&](Dispatcher& d, ToolListCallback cb) { + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { server->listTools(d, std::move(cb)); }); From bb3a9fb507cc862c60a0c6fb4e1f58a42dac2e21 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:48:04 +0530 Subject: [PATCH 140/197] Update ToolRegistry tests for ServerToolInfo (#24) --- tests/gopher/orch/tool_registry_test.cc | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/gopher/orch/tool_registry_test.cc b/tests/gopher/orch/tool_registry_test.cc index 5616244d..b3da45a6 100644 --- a/tests/gopher/orch/tool_registry_test.cc +++ b/tests/gopher/orch/tool_registry_test.cc @@ -283,8 +283,8 @@ TEST_F(ToolRegistryTest, AddServerWithToolList) { dispatcher_->run(mcp::event::RunType::NonBlock); // Get tool list from server - auto tools = runToCompletion>( - [&](Dispatcher& d, ToolListCallback cb) { + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { mock_server_->listTools(d, std::move(cb)); }); @@ -308,8 +308,8 @@ TEST_F(ToolRegistryTest, ExecuteServerTool) { mock_server_->connect(*dispatcher_, [](Result) {}); dispatcher_->run(mcp::event::RunType::NonBlock); - auto tools = runToCompletion>( - [&](Dispatcher& d, ToolListCallback cb) { + auto tools = runToCompletion>( + [&](Dispatcher& d, ServerToolListCallback cb) { mock_server_->listTools(d, std::move(cb)); }); @@ -331,7 +331,7 @@ TEST_F(ToolRegistryTest, AddServerToolWithAlias) { mock_server_->connect(*dispatcher_, [](Result) {}); dispatcher_->run(mcp::event::RunType::NonBlock); - ToolInfo info("original_name", "Original tool"); + ServerToolInfo info("original_name", "Original tool"); registry_->addServerTool(mock_server_, info, "aliased_name"); EXPECT_TRUE(registry_->hasTool("aliased_name")); @@ -404,8 +404,8 @@ TEST_F(ToolRegistryTest, GetToolEntry) { // Conversion Utility Tests // ============================================================================= -TEST(ToolConversionTest, ToolInfoToToolSpec) { - ToolInfo info; +TEST(ToolConversionTest, ServerToolInfoToToolSpec) { + ServerToolInfo info; info.name = "test_tool"; info.description = "Test description"; info.inputSchema = JsonValue::object(); @@ -418,14 +418,14 @@ TEST(ToolConversionTest, ToolInfoToToolSpec) { EXPECT_TRUE(spec.parameters.contains("type")); } -TEST(ToolConversionTest, ToolSpecToToolInfo) { +TEST(ToolConversionTest, ToolSpecToServerToolInfo) { ToolSpec spec; spec.name = "another_tool"; spec.description = "Another description"; spec.parameters = JsonValue::object(); spec.parameters["type"] = "object"; - ToolInfo info = toToolInfo(spec); + ServerToolInfo info = toServerToolInfo(spec); EXPECT_EQ(info.name, "another_tool"); EXPECT_EQ(info.description, "Another description"); From c8abbaa05133435ed87dfc83521e936d25652fa1 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:48:13 +0530 Subject: [PATCH 141/197] Update documentation for ServerToolInfo rename (#24) --- docs/ToolRegistry.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ToolRegistry.md b/docs/ToolRegistry.md index 5bec93f3..3eec217d 100644 --- a/docs/ToolRegistry.md +++ b/docs/ToolRegistry.md @@ -147,7 +147,7 @@ struct ToolEntry { ║ └────────────────────────┘ ║ ║ │ ║ ║ ▼ ║ -║ For each ToolInfo: ║ +║ For each ServerToolInfo: ║ ║ ┌─────────────────────────────┐ ║ ║ │ Create ToolEntry │ ║ ║ │ • spec = toToolSpec(info) │ ║ @@ -296,9 +296,9 @@ registry->addServer(weatherServer, dispatcher); registry->addServerTool(weatherServer, "get_forecast", "forecast"); // Or provide tool list directly (sync) -std::vector tools = { - ToolInfo{"get_weather", "Get current weather", weatherSchema}, - ToolInfo{"get_forecast", "Get weather forecast", forecastSchema} +std::vector tools = { + ServerToolInfo{"get_weather", "Get current weather", weatherSchema}, + ServerToolInfo{"get_forecast", "Get weather forecast", forecastSchema} }; registry->addServer(weatherServer, tools); ``` From 02c037a9676c7bbb1bd6c52ec187eeec29e01cab Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:51:34 +0530 Subject: [PATCH 142/197] Rename MCPReference to ToolDef and RESTEndpoint to RESTEndpointToolDef (#24) --- include/gopher/orch/agent/tool_definition.h | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/include/gopher/orch/agent/tool_definition.h b/include/gopher/orch/agent/tool_definition.h index 75e6b526..4714cbc8 100644 --- a/include/gopher/orch/agent/tool_definition.h +++ b/include/gopher/orch/agent/tool_definition.h @@ -39,7 +39,7 @@ struct ToolDefinition { // ───────────────────────────────────────────────────────────────────────── // Option 1: REST Endpoint // ───────────────────────────────────────────────────────────────────────── - struct RESTEndpoint { + struct RESTEndpointToolDef { HttpMethod method = HttpMethod::GET; std::string url; // Full URL or path (supports ${ENV_VAR}) std::map headers; @@ -52,22 +52,22 @@ struct ToolDefinition { // Response extraction std::string response_path; // JSONPath to extract from response - RESTEndpoint() = default; + RESTEndpointToolDef() = default; }; - optional rest_endpoint; + optional rest_endpoint; // ───────────────────────────────────────────────────────────────────────── // Option 2: MCP Server Reference // ───────────────────────────────────────────────────────────────────────── - struct MCPReference { + struct ToolDef { std::string server_name; // Name of registered MCP server std::string tool_name; // Tool name on that server - MCPReference() = default; - MCPReference(const std::string& server, const std::string& tool) + ToolDef() = default; + ToolDef(const std::string& server, const std::string& tool) : server_name(server), tool_name(tool) {} }; - optional mcp_reference; + optional mcp_reference; // ───────────────────────────────────────────────────────────────────────── // Option 3: Lambda/Function (programmatic only) @@ -97,14 +97,14 @@ struct ToolDefinition { return *this; } - ToolDefinition& withRESTEndpoint(const RESTEndpoint& ep) { + ToolDefinition& withRESTEndpoint(const RESTEndpointToolDef& ep) { rest_endpoint = ep; return *this; } ToolDefinition& withMCPReference(const std::string& server, const std::string& tool) { - mcp_reference = MCPReference(server, tool); + mcp_reference = ToolDef(server, tool); return *this; } From d22dc76001bfbb3792d0ec9d3636a1834471c52e Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:51:49 +0530 Subject: [PATCH 143/197] Update ConfigLoader for renamed tool definition types (#24) --- include/gopher/orch/agent/config_loader.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/gopher/orch/agent/config_loader.h b/include/gopher/orch/agent/config_loader.h index c97baa54..bd0386de 100644 --- a/include/gopher/orch/agent/config_loader.h +++ b/include/gopher/orch/agent/config_loader.h @@ -271,7 +271,7 @@ inline Result ConfigLoader::parseToolDefinition( // Parse REST endpoint if (json.contains("rest_endpoint")) { const auto& ep = json["rest_endpoint"]; - ToolDefinition::RESTEndpoint rest; + ToolDefinition::RESTEndpointToolDef rest; rest.method = parseHttpMethod(ep.contains("method") ? ep["method"].getString() : "GET"); rest.url = substituteEnvVars(ep.contains("url") ? ep["url"].getString() : ""); @@ -311,7 +311,7 @@ inline Result ConfigLoader::parseToolDefinition( // Parse MCP reference if (json.contains("mcp_reference")) { const auto& ref = json["mcp_reference"]; - ToolDefinition::MCPReference mcp; + ToolDefinition::ToolDef 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); From 33eb8f599ca8534b3bdb6c9c74f1ba8f140d8b71 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:51:52 +0530 Subject: [PATCH 144/197] Update RESTToolAdapter for RESTEndpointToolDef (#24) --- include/gopher/orch/agent/rest_tool_adapter.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h index 24584524..26afc2fd 100644 --- a/include/gopher/orch/agent/rest_tool_adapter.h +++ b/include/gopher/orch/agent/rest_tool_adapter.h @@ -2,7 +2,7 @@ // RESTToolAdapter - Create tools from REST endpoint definitions // -// Converts ToolDefinition with RESTEndpoint to executable tools. +// Converts ToolDefinition with RESTEndpointToolDef to executable tools. // Supports: // - Path parameter substitution (/users/{id}) // - Query parameter mapping ($.field) @@ -151,7 +151,7 @@ class RESTToolAdapter { } // Execute a REST call directly - void executeRESTCall(const ToolDefinition::RESTEndpoint& endpoint, + void executeRESTCall(const ToolDefinition::RESTEndpointToolDef& endpoint, const JsonValue& input, Dispatcher& dispatcher, JsonCallback callback) { From 45b869d1c995ff7b917a63ab3ecf1023cff952e7 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 13:51:55 +0530 Subject: [PATCH 145/197] Update tests for renamed tool definition types (#24) --- tests/gopher/orch/tool_registry_test.cc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/gopher/orch/tool_registry_test.cc b/tests/gopher/orch/tool_registry_test.cc index b3da45a6..6657a6d6 100644 --- a/tests/gopher/orch/tool_registry_test.cc +++ b/tests/gopher/orch/tool_registry_test.cc @@ -664,8 +664,8 @@ TEST(ToolDefinitionTest, ToToolSpec) { EXPECT_TRUE(spec.parameters.contains("type")); } -TEST(ToolDefinitionTest, RESTEndpoint) { - ToolDefinition::RESTEndpoint rest; +TEST(ToolDefinitionTest, RESTEndpointToolDef) { + ToolDefinition::RESTEndpointToolDef rest; rest.method = HttpMethod::POST; rest.url = "https://api.example.com/search"; rest.headers["Content-Type"] = "application/json"; @@ -676,8 +676,8 @@ TEST(ToolDefinitionTest, RESTEndpoint) { EXPECT_EQ(rest.headers["Content-Type"], "application/json"); } -TEST(ToolDefinitionTest, MCPReference) { - ToolDefinition::MCPReference ref; +TEST(ToolDefinitionTest, ToolDef) { + ToolDefinition::ToolDef ref; ref.server_name = "mcp-server"; ref.tool_name = "remote_tool"; From bb6daa59724b5264a052992cad430e141549d39f Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 14:00:24 +0530 Subject: [PATCH 146/197] Rename source files from .cpp to .cc (#24) --- src/CMakeLists.txt | 18 +++++++++--------- src/gopher/orch/agent/{agent.cpp => agent.cc} | 0 .../{config_loader.cpp => config_loader.cc} | 0 .../{tool_registry.cpp => tool_registry.cc} | 0 ...opic_provider.cpp => anthropic_provider.cc} | 0 .../llm/{llm_factory.cpp => llm_factory.cc} | 0 ...{openai_provider.cpp => openai_provider.cc} | 0 .../server/{mcp_server.cpp => mcp_server.cc} | 0 .../server/{rest_server.cpp => rest_server.cc} | 0 src/orch/{hello.cpp => hello.cc} | 0 10 files changed, 9 insertions(+), 9 deletions(-) rename src/gopher/orch/agent/{agent.cpp => agent.cc} (100%) rename src/gopher/orch/agent/{config_loader.cpp => config_loader.cc} (100%) rename src/gopher/orch/agent/{tool_registry.cpp => tool_registry.cc} (100%) rename src/gopher/orch/llm/{anthropic_provider.cpp => anthropic_provider.cc} (100%) rename src/gopher/orch/llm/{llm_factory.cpp => llm_factory.cc} (100%) rename src/gopher/orch/llm/{openai_provider.cpp => openai_provider.cc} (100%) rename src/gopher/orch/server/{mcp_server.cpp => mcp_server.cc} (100%) rename src/gopher/orch/server/{rest_server.cpp => rest_server.cc} (100%) rename src/orch/{hello.cpp => hello.cc} (100%) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 96a3394c..a9087d8a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,7 +2,7 @@ # Core library sources (orch-specific extensions) set(ORCH_CORE_SOURCES - orch/hello.cpp + orch/hello.cc ) # MCP Server sources (requires gopher-mcp) @@ -10,8 +10,8 @@ set(ORCH_CORE_SOURCES set(ORCH_MCP_SOURCES "") if(NOT BUILD_WITHOUT_GOPHER_MCP) set(ORCH_MCP_SOURCES - gopher/orch/server/mcp_server.cpp - gopher/orch/server/rest_server.cpp + gopher/orch/server/mcp_server.cc + gopher/orch/server/rest_server.cc ) endif() @@ -19,9 +19,9 @@ endif() set(ORCH_LLM_SOURCES "") if(NOT BUILD_WITHOUT_GOPHER_MCP) set(ORCH_LLM_SOURCES - gopher/orch/llm/openai_provider.cpp - gopher/orch/llm/anthropic_provider.cpp - gopher/orch/llm/llm_factory.cpp + gopher/orch/llm/openai_provider.cc + gopher/orch/llm/anthropic_provider.cc + gopher/orch/llm/llm_factory.cc ) endif() @@ -29,9 +29,9 @@ endif() set(ORCH_AGENT_SOURCES "") if(NOT BUILD_WITHOUT_GOPHER_MCP) set(ORCH_AGENT_SOURCES - gopher/orch/agent/agent.cpp - gopher/orch/agent/config_loader.cpp - gopher/orch/agent/tool_registry.cpp + gopher/orch/agent/agent.cc + gopher/orch/agent/config_loader.cc + gopher/orch/agent/tool_registry.cc ) endif() diff --git a/src/gopher/orch/agent/agent.cpp b/src/gopher/orch/agent/agent.cc similarity index 100% rename from src/gopher/orch/agent/agent.cpp rename to src/gopher/orch/agent/agent.cc diff --git a/src/gopher/orch/agent/config_loader.cpp b/src/gopher/orch/agent/config_loader.cc similarity index 100% rename from src/gopher/orch/agent/config_loader.cpp rename to src/gopher/orch/agent/config_loader.cc diff --git a/src/gopher/orch/agent/tool_registry.cpp b/src/gopher/orch/agent/tool_registry.cc similarity index 100% rename from src/gopher/orch/agent/tool_registry.cpp rename to src/gopher/orch/agent/tool_registry.cc diff --git a/src/gopher/orch/llm/anthropic_provider.cpp b/src/gopher/orch/llm/anthropic_provider.cc similarity index 100% rename from src/gopher/orch/llm/anthropic_provider.cpp rename to src/gopher/orch/llm/anthropic_provider.cc diff --git a/src/gopher/orch/llm/llm_factory.cpp b/src/gopher/orch/llm/llm_factory.cc similarity index 100% rename from src/gopher/orch/llm/llm_factory.cpp rename to src/gopher/orch/llm/llm_factory.cc diff --git a/src/gopher/orch/llm/openai_provider.cpp b/src/gopher/orch/llm/openai_provider.cc similarity index 100% rename from src/gopher/orch/llm/openai_provider.cpp rename to src/gopher/orch/llm/openai_provider.cc diff --git a/src/gopher/orch/server/mcp_server.cpp b/src/gopher/orch/server/mcp_server.cc similarity index 100% rename from src/gopher/orch/server/mcp_server.cpp rename to src/gopher/orch/server/mcp_server.cc diff --git a/src/gopher/orch/server/rest_server.cpp b/src/gopher/orch/server/rest_server.cc similarity index 100% rename from src/gopher/orch/server/rest_server.cpp rename to src/gopher/orch/server/rest_server.cc diff --git a/src/orch/hello.cpp b/src/orch/hello.cc similarity index 100% rename from src/orch/hello.cpp rename to src/orch/hello.cc From c041b090f3880947b770fd46b742624ac754e623 Mon Sep 17 00:00:00 2001 From: Divyansh Ingle Date: Thu, 1 Jan 2026 14:04:09 +0530 Subject: [PATCH 147/197] Format code using make format (#24) --- include/gopher/orch/agent/agent_module.h | 2 +- include/gopher/orch/agent/agent_types.h | 21 +- include/gopher/orch/agent/config_loader.h | 122 ++++++++---- include/gopher/orch/agent/rest_tool_adapter.h | 25 +-- include/gopher/orch/agent/tool_definition.h | 26 +-- include/gopher/orch/agent/tool_executor.h | 24 +-- include/gopher/orch/agent/tool_registry.h | 85 ++++---- include/gopher/orch/llm/anthropic_provider.h | 3 +- include/gopher/orch/llm/llm.h | 5 +- include/gopher/orch/llm/llm_provider.h | 25 ++- include/gopher/orch/llm/llm_types.h | 30 +-- include/gopher/orch/llm/openai_provider.h | 8 +- include/gopher/orch/orch.h | 8 +- include/gopher/orch/server/mcp_server.h | 3 +- include/gopher/orch/server/mock_server.h | 3 +- include/gopher/orch/server/rest_server.h | 7 +- include/gopher/orch/server/server.h | 6 +- src/gopher/orch/agent/agent.cc | 72 +++---- src/gopher/orch/agent/config_loader.cc | 12 +- src/gopher/orch/agent/tool_registry.cc | 130 +++++++------ src/gopher/orch/llm/anthropic_provider.cc | 68 +++---- src/gopher/orch/llm/llm_factory.cc | 7 +- src/gopher/orch/llm/openai_provider.cc | 61 +++--- src/gopher/orch/server/mcp_server.cc | 11 +- src/gopher/orch/server/rest_server.cc | 3 +- tests/gopher/orch/agent_test.cc | 113 +++++------ tests/gopher/orch/llm_provider_test.cc | 40 ++-- tests/gopher/orch/mock_http_client.h | 46 ++--- tests/gopher/orch/mock_llm_provider.h | 33 ++-- tests/gopher/orch/server_composite_test.cc | 4 +- tests/gopher/orch/tool_registry_test.cc | 183 +++++++++--------- 31 files changed, 625 insertions(+), 561 deletions(-) diff --git a/include/gopher/orch/agent/agent_module.h b/include/gopher/orch/agent/agent_module.h index f04d73d9..679ba753 100644 --- a/include/gopher/orch/agent/agent_module.h +++ b/include/gopher/orch/agent/agent_module.h @@ -41,9 +41,9 @@ #include "gopher/orch/agent/agent_types.h" // Tool definitions and configuration -#include "gopher/orch/agent/tool_definition.h" #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" diff --git a/include/gopher/orch/agent/agent_types.h b/include/gopher/orch/agent/agent_types.h index ebdfb106..1a6e1cf8 100644 --- a/include/gopher/orch/agent/agent_types.h +++ b/include/gopher/orch/agent/agent_types.h @@ -92,11 +92,11 @@ struct AgentConfig { // Current state of agent execution enum class AgentStatus { - IDLE, // Not started - RUNNING, // Currently executing - COMPLETED, // Finished successfully - FAILED, // Error occurred - CANCELLED, // Cancelled by user + IDLE, // Not started + RUNNING, // Currently executing + COMPLETED, // Finished successfully + FAILED, // Error occurred + CANCELLED, // Cancelled by user MAX_ITERATIONS_REACHED // Hit iteration limit }; @@ -178,7 +178,8 @@ struct AgentState { // Get last message content std::string lastContent() const { - if (messages.empty()) return ""; + if (messages.empty()) + return ""; return messages.back().content; } }; @@ -209,14 +210,10 @@ struct AgentResult { optional error; // Check if successful - bool isSuccess() const { - return status == AgentStatus::COMPLETED; - } + bool isSuccess() const { return status == AgentStatus::COMPLETED; } // Get number of iterations - int iterationCount() const { - return static_cast(steps.size()); - } + int iterationCount() const { return static_cast(steps.size()); } }; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/include/gopher/orch/agent/config_loader.h b/include/gopher/orch/agent/config_loader.h index bd0386de..5067b523 100644 --- a/include/gopher/orch/agent/config_loader.h +++ b/include/gopher/orch/agent/config_loader.h @@ -119,20 +119,29 @@ class ConfigLoader { // 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; +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 == "stdio") + return MCPServerDefinition::TransportType::STDIO; if (transport == "http_sse" || transport == "http-sse" || transport == "sse") return MCPServerDefinition::TransportType::HTTP_SSE; if (transport == "websocket" || transport == "ws") @@ -143,7 +152,8 @@ inline MCPServerDefinition::TransportType ConfigLoader::parseTransportType( inline Result ConfigLoader::parseAuthPreset(const JsonValue& json) { AuthPreset auth; - std::string type = json.contains("type") ? json["type"].getString() : "bearer"; + 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") { @@ -152,8 +162,10 @@ inline Result ConfigLoader::parseAuthPreset(const JsonValue& json) { auth.type = AuthPreset::Type::BASIC; } - auth.value = substituteEnvVars(json.contains("value") ? json["value"].getString() : ""); - auth.header = json.contains("header") ? json["header"].getString() : "Authorization"; + auth.value = substituteEnvVars( + json.contains("value") ? json["value"].getString() : ""); + auth.header = + json.contains("header") ? json["header"].getString() : "Authorization"; return Result(std::move(auth)); } @@ -168,7 +180,8 @@ inline Result ConfigLoader::parseMCPServerDefinition( Error(-1, "MCP server definition missing 'name'")); } - std::string transport = json.contains("transport") ? json["transport"].getString() : "stdio"; + std::string transport = + json.contains("transport") ? json["transport"].getString() : "stdio"; def.transport = parseTransportType(transport); // Parse transport-specific config @@ -177,7 +190,8 @@ inline Result ConfigLoader::parseMCPServerDefinition( if (json.contains("stdio")) { const auto& stdio = json["stdio"]; MCPServerDefinition::StdioConfig cfg; - cfg.command = substituteEnvVars(stdio.contains("command") ? stdio["command"].getString() : ""); + cfg.command = substituteEnvVars( + stdio.contains("command") ? stdio["command"].getString() : ""); if (stdio.contains("args") && stdio["args"].isArray()) { const auto& args = stdio["args"]; @@ -193,7 +207,9 @@ inline Result ConfigLoader::parseMCPServerDefinition( } } - cfg.working_directory = stdio.contains("working_directory") ? stdio["working_directory"].getString() : ""; + cfg.working_directory = stdio.contains("working_directory") + ? stdio["working_directory"].getString() + : ""; def.stdio_config = std::move(cfg); } break; @@ -203,11 +219,14 @@ inline Result ConfigLoader::parseMCPServerDefinition( if (json.contains("http_sse")) { const auto& sse = json["http_sse"]; MCPServerDefinition::HttpSseConfig cfg; - cfg.url = substituteEnvVars(sse.contains("url") ? sse["url"].getString() : ""); - cfg.verify_ssl = sse.contains("verify_ssl") ? sse["verify_ssl"].getBool() : true; + cfg.url = substituteEnvVars(sse.contains("url") ? sse["url"].getString() + : ""); + cfg.verify_ssl = + sse.contains("verify_ssl") ? sse["verify_ssl"].getBool() : true; if (sse.contains("headers") && sse["headers"].isObject()) { - for (auto it = sse["headers"].begin(); it != sse["headers"].end(); ++it) { + for (auto it = sse["headers"].begin(); it != sse["headers"].end(); + ++it) { auto kv = *it; cfg.headers[kv.first] = substituteEnvVars(kv.second.getString()); } @@ -222,11 +241,14 @@ inline Result ConfigLoader::parseMCPServerDefinition( if (json.contains("websocket")) { const auto& ws = json["websocket"]; MCPServerDefinition::WebSocketConfig cfg; - cfg.url = substituteEnvVars(ws.contains("url") ? ws["url"].getString() : ""); - cfg.verify_ssl = ws.contains("verify_ssl") ? ws["verify_ssl"].getBool() : true; + cfg.url = + substituteEnvVars(ws.contains("url") ? ws["url"].getString() : ""); + cfg.verify_ssl = + ws.contains("verify_ssl") ? ws["verify_ssl"].getBool() : true; if (ws.contains("headers") && ws["headers"].isObject()) { - for (auto it = ws["headers"].begin(); it != ws["headers"].end(); ++it) { + for (auto it = ws["headers"].begin(); it != ws["headers"].end(); + ++it) { auto kv = *it; cfg.headers[kv.first] = substituteEnvVars(kv.second.getString()); } @@ -240,10 +262,12 @@ inline Result ConfigLoader::parseMCPServerDefinition( // Parse timeouts if (json.contains("connect_timeout_ms")) { - def.connect_timeout = std::chrono::milliseconds(json["connect_timeout_ms"].getInt()); + def.connect_timeout = + std::chrono::milliseconds(json["connect_timeout_ms"].getInt()); } if (json.contains("request_timeout_ms")) { - def.request_timeout = std::chrono::milliseconds(json["request_timeout_ms"].getInt()); + def.request_timeout = + std::chrono::milliseconds(json["request_timeout_ms"].getInt()); } if (json.contains("max_retries")) { def.max_retries = static_cast(json["max_retries"].getInt()); @@ -258,11 +282,11 @@ inline Result ConfigLoader::parseToolDefinition( def.name = json.contains("name") ? json["name"].getString() : ""; if (def.name.empty()) { - return Result( - Error(-1, "Tool definition missing 'name'")); + return Result(Error(-1, "Tool definition missing 'name'")); } - def.description = json.contains("description") ? json["description"].getString() : ""; + def.description = + json.contains("description") ? json["description"].getString() : ""; if (json.contains("input_schema")) { def.input_schema = json["input_schema"]; @@ -273,8 +297,10 @@ inline Result ConfigLoader::parseToolDefinition( const auto& ep = json["rest_endpoint"]; ToolDefinition::RESTEndpointToolDef rest; - rest.method = parseHttpMethod(ep.contains("method") ? ep["method"].getString() : "GET"); - rest.url = substituteEnvVars(ep.contains("url") ? ep["url"].getString() : ""); + 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) { @@ -284,27 +310,31 @@ inline Result ConfigLoader::parseToolDefinition( } if (ep.contains("query_params") && ep["query_params"].isObject()) { - for (auto it = ep["query_params"].begin(); it != ep["query_params"].end(); ++it) { + 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) { + 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) { + 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() : ""; + rest.response_path = + ep.contains("response_path") ? ep["response_path"].getString() : ""; def.rest_endpoint = std::move(rest); } @@ -312,8 +342,10 @@ inline Result ConfigLoader::parseToolDefinition( if (json.contains("mcp_reference")) { const auto& ref = json["mcp_reference"]; ToolDefinition::ToolDef mcp; - mcp.server_name = ref.contains("server_name") ? ref["server_name"].getString() : ""; - mcp.tool_name = ref.contains("tool_name") ? ref["tool_name"].getString() : ""; + 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); } @@ -325,23 +357,29 @@ inline Result ConfigLoader::parseToolDefinition( } } - def.require_approval = json.contains("require_approval") ? json["require_approval"].getBool() : false; + def.require_approval = json.contains("require_approval") + ? json["require_approval"].getBool() + : false; return Result(std::move(def)); } -inline Result ConfigLoader::loadFromJson(const JsonValue& json) { +inline Result ConfigLoader::loadFromJson( + const JsonValue& json) { RegistryConfig config; - config.name = json.contains("name") ? json["name"].getString() : "tool-registry"; - config.base_url = substituteEnvVars(json.contains("base_url") ? json["base_url"].getString() : ""); + config.name = + json.contains("name") ? json["name"].getString() : "tool-registry"; + config.base_url = substituteEnvVars( + json.contains("base_url") ? json["base_url"].getString() : ""); // Parse default headers if (json.contains("default_headers") && json["default_headers"].isObject()) { for (auto it = json["default_headers"].begin(); it != json["default_headers"].end(); ++it) { auto kv = *it; - config.default_headers[kv.first] = substituteEnvVars(kv.second.getString()); + config.default_headers[kv.first] = + substituteEnvVars(kv.second.getString()); } } @@ -363,7 +401,8 @@ inline Result ConfigLoader::loadFromJson(const JsonValue& json) for (size_t i = 0; i < servers.size(); ++i) { auto server_result = parseMCPServerDefinition(servers[i]); if (mcp::holds_alternative(server_result)) { - config.mcp_servers.push_back(std::move(mcp::get(server_result))); + config.mcp_servers.push_back( + std::move(mcp::get(server_result))); } } } @@ -374,7 +413,8 @@ inline Result ConfigLoader::loadFromJson(const JsonValue& json) 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))); + config.tools.push_back( + std::move(mcp::get(tool_result))); } } } diff --git a/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h index 26afc2fd..2b4902d6 100644 --- a/include/gopher/orch/agent/rest_tool_adapter.h +++ b/include/gopher/orch/agent/rest_tool_adapter.h @@ -25,16 +25,16 @@ namespace agent { using namespace gopher::orch::server; // Tool execution function signature (also defined in tool_registry.h) -using ToolFunction = std::function; +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) { +inline JsonValue extractJsonPath(const JsonValue& json, + const std::string& path) { if (path.empty() || path == "$") { return json; } @@ -53,7 +53,8 @@ inline JsonValue extractJsonPath(const JsonValue& json, const std::string& path) std::string token; while (std::getline(iss, token, '.')) { - if (token.empty()) continue; + if (token.empty()) + continue; // Check for array index [n] auto bracket_pos = token.find('['); @@ -87,7 +88,7 @@ inline JsonValue extractJsonPath(const JsonValue& json, const std::string& path) // Extract value as string inline std::string extractJsonPathString(const JsonValue& json, - const std::string& path) { + const std::string& path) { JsonValue value = extractJsonPath(json, path); if (value.isNull()) { return ""; @@ -121,7 +122,7 @@ class RESTToolAdapter { public: explicit RESTToolAdapter(HttpClientPtr http_client = nullptr) : http_client_(http_client ? http_client - : std::make_shared()) {} + : std::make_shared()) {} // Set default headers for all requests void setDefaultHeaders(const std::map& headers) { @@ -174,7 +175,8 @@ class RESTToolAdapter { 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)); + std::string value = + substituteEnvVars(extractJsonPathString(input, kv.second)); if (!value.empty()) { url += (has_query ? "&" : "?"); url += urlEncode(kv.first) + "=" + urlEncode(value); @@ -211,7 +213,8 @@ class RESTToolAdapter { // Make request http_client_->request( endpoint.method, url, headers, body, dispatcher, - [endpoint, callback = std::move(callback)](Result result) { + [endpoint, + callback = std::move(callback)](Result result) { if (!mcp::holds_alternative(result)) { callback(Result(mcp::get(result))); return; @@ -220,8 +223,8 @@ class RESTToolAdapter { auto& response = mcp::get(result); if (!response.isSuccess()) { callback(Result( - Error(-1, "HTTP " + std::to_string(response.status_code) + ": " + - response.body))); + Error(-1, "HTTP " + std::to_string(response.status_code) + + ": " + response.body))); return; } diff --git a/include/gopher/orch/agent/tool_definition.h b/include/gopher/orch/agent/tool_definition.h index 4714cbc8..621dced7 100644 --- a/include/gopher/orch/agent/tool_definition.h +++ b/include/gopher/orch/agent/tool_definition.h @@ -72,7 +72,8 @@ struct ToolDefinition { // ───────────────────────────────────────────────────────────────────────── // Option 3: Lambda/Function (programmatic only) // ───────────────────────────────────────────────────────────────────────── - using Handler = std::function; + using Handler = + std::function; optional handler; // Metadata @@ -103,7 +104,7 @@ struct ToolDefinition { } ToolDefinition& withMCPReference(const std::string& server, - const std::string& tool) { + const std::string& tool) { mcp_reference = ToolDef(server, tool); return *this; } @@ -189,8 +190,8 @@ struct MCPServerDefinition { // Builder pattern for STDIO static MCPServerDefinition stdio(const std::string& name, - const std::string& command, - const std::vector& args = {}) { + const std::string& command, + const std::vector& args = {}) { MCPServerDefinition def(name); def.transport = TransportType::STDIO; def.stdio_config = StdioConfig(command, args); @@ -199,7 +200,7 @@ struct MCPServerDefinition { // Builder pattern for HTTP-SSE static MCPServerDefinition httpSse(const std::string& name, - const std::string& url) { + const std::string& url) { MCPServerDefinition def(name); def.transport = TransportType::HTTP_SSE; def.http_sse_config = HttpSseConfig(url); @@ -208,21 +209,23 @@ struct MCPServerDefinition { // Builder pattern for WebSocket static MCPServerDefinition websocket(const std::string& name, - const std::string& url) { + 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) { + 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) { + MCPServerDefinition& withHeader(const std::string& key, + const std::string& value) { if (http_sse_config) { http_sse_config->headers[key] = value; } else if (websocket_config) { @@ -246,8 +249,8 @@ 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 + std::string value; // Token/key (supports ${ENV_VAR}) + std::string header = "Authorization"; // Header name for API_KEY AuthPreset() = default; @@ -329,7 +332,8 @@ struct RegistryConfig { return *this; } - RegistryConfig& withAuthPreset(const std::string& name, const AuthPreset& auth) { + RegistryConfig& withAuthPreset(const std::string& name, + const AuthPreset& auth) { auth_presets[name] = auth; return *this; } diff --git a/include/gopher/orch/agent/tool_executor.h b/include/gopher/orch/agent/tool_executor.h index 8b4cbdc5..843c7fd4 100644 --- a/include/gopher/orch/agent/tool_executor.h +++ b/include/gopher/orch/agent/tool_executor.h @@ -42,7 +42,8 @@ class ToolExecutor { public: using Ptr = std::shared_ptr; - explicit ToolExecutor(ToolRegistryPtr registry) : registry_(std::move(registry)) {} + explicit ToolExecutor(ToolRegistryPtr registry) + : registry_(std::move(registry)) {} ~ToolExecutor() = default; // Factory @@ -85,9 +86,8 @@ class ToolExecutor { } else { // Execute on remote server using original name RunnableConfig config; - std::string tool_name = entry.original_name.empty() - ? entry.spec.name - : entry.original_name; + 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)); } @@ -101,18 +101,18 @@ class ToolExecutor { } // Execute multiple tool calls (optionally in parallel) - void executeToolCalls(const std::vector& calls, - bool parallel, - Dispatcher& dispatcher, - std::function>)> callback) { + void executeToolCalls( + const std::vector& calls, + bool parallel, + Dispatcher& dispatcher, + std::function>)> callback) { if (calls.empty()) { - dispatcher.post([callback = std::move(callback)]() { - callback({}); - }); + dispatcher.post([callback = std::move(callback)]() { callback({}); }); return; } - auto results = std::make_shared>>(calls.size()); + auto results = + std::make_shared>>(calls.size()); auto pending = std::make_shared>(calls.size()); for (size_t i = 0; i < calls.size(); ++i) { diff --git a/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h index df62745e..998e9bdb 100644 --- a/include/gopher/orch/agent/tool_registry.h +++ b/include/gopher/orch/agent/tool_registry.h @@ -66,9 +66,8 @@ class ToolRegistry; using ToolRegistryPtr = std::shared_ptr; // Tool execution function signature -using ToolFunction = std::function; +using ToolFunction = std::function; // ═══════════════════════════════════════════════════════════════════════════ // CONVERSION UTILITIES @@ -97,7 +96,8 @@ 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) + 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; } @@ -107,7 +107,8 @@ struct ToolEntry { // // Thread Safety: // - Configuration methods (addTool, addServer) should be called before use -// - Read methods (getToolSpecs, getToolEntry) are thread-safe after configuration +// - Read methods (getToolSpecs, getToolEntry) are thread-safe after +// configuration class ToolRegistry { public: using Ptr = std::shared_ptr; @@ -145,10 +146,11 @@ class ToolRegistry { } // 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) { + 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, @@ -167,7 +169,8 @@ class ToolRegistry { // Add all tools from a server (async - fetches tool list) void addServer(ServerPtr server, Dispatcher& dispatcher) { - if (!server) return; + if (!server) + return; // Store server reference { @@ -176,31 +179,35 @@ class ToolRegistry { } // List and register tools - server->listTools(dispatcher, [this, server](Result> result) { - if (!mcp::holds_alternative>(result)) return; - - std::lock_guard lock(mutex_); - for (const auto& info : mcp::get>(result)) { - 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; - } - } - }); + server->listTools( + dispatcher, [this, server](Result> result) { + if (!mcp::holds_alternative>(result)) + return; + + std::lock_guard lock(mutex_); + for (const auto& info : + mcp::get>(result)) { + 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; + if (!server) + return; std::lock_guard lock(mutex_); servers_.push_back(server); @@ -224,7 +231,8 @@ class ToolRegistry { void addServerTool(ServerPtr server, const ServerToolInfo& info, const std::string& alias = "") { - if (!server) return; + if (!server) + return; std::lock_guard lock(mutex_); @@ -244,7 +252,8 @@ class ToolRegistry { void addServerTool(ServerPtr server, const std::string& tool_name, const std::string& alias = "") { - if (!server) return; + if (!server) + return; std::lock_guard lock(mutex_); @@ -339,7 +348,8 @@ class ToolRegistry { } // ═══════════════════════════════════════════════════════════════════════════ - // CONFIG LOADING (requires tool_definition.h, config_loader.h, rest_tool_adapter.h) + // CONFIG LOADING (requires tool_definition.h, config_loader.h, + // rest_tool_adapter.h) // ═══════════════════════════════════════════════════════════════════════════ // Load from JSON config file @@ -360,8 +370,7 @@ class ToolRegistry { std::function callback); // Register a tool from ToolDefinition - VoidResult registerTool(const ToolDefinition& def, - Dispatcher& dispatcher); + VoidResult registerTool(const ToolDefinition& def, Dispatcher& dispatcher); // ═══════════════════════════════════════════════════════════════════════════ // ENVIRONMENT VARIABLES @@ -411,9 +420,7 @@ class ToolRegistry { }; // Convenience function to create registry -inline ToolRegistryPtr makeToolRegistry() { - return ToolRegistry::create(); -} +inline ToolRegistryPtr makeToolRegistry() { return ToolRegistry::create(); } } // namespace agent } // namespace orch diff --git a/include/gopher/orch/llm/anthropic_provider.h b/include/gopher/orch/llm/anthropic_provider.h index 88c0ad7d..c538bead 100644 --- a/include/gopher/orch/llm/anthropic_provider.h +++ b/include/gopher/orch/llm/anthropic_provider.h @@ -3,7 +3,8 @@ // 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.) +// Compatible with Claude models (claude-3-opus, claude-3-sonnet, +// claude-3-haiku, etc.) // // Usage: // auto provider = AnthropicProvider::create("sk-ant-..."); diff --git a/include/gopher/orch/llm/llm.h b/include/gopher/orch/llm/llm.h index 62df6a47..10de6dd2 100644 --- a/include/gopher/orch/llm/llm.h +++ b/include/gopher/orch/llm/llm.h @@ -21,7 +21,8 @@ // Message::user("Hello!") // }; // -// provider->chat(messages, {}, config, dispatcher, [](Result r) { +// provider->chat(messages, {}, config, dispatcher, [](Result r) +// { // if (r.isOk()) { // std::cout << r.value().message.content << std::endl; // } @@ -34,8 +35,8 @@ #include "gopher/orch/llm/llm_provider.h" // Provider implementations -#include "gopher/orch/llm/openai_provider.h" #include "gopher/orch/llm/anthropic_provider.h" +#include "gopher/orch/llm/openai_provider.h" namespace gopher { namespace orch { diff --git a/include/gopher/orch/llm/llm_provider.h b/include/gopher/orch/llm/llm_provider.h index 4fe99193..d322c57a 100644 --- a/include/gopher/orch/llm/llm_provider.h +++ b/include/gopher/orch/llm/llm_provider.h @@ -11,7 +11,8 @@ // LLMConfig config("gpt-4"); // config.withTemperature(0.7); // -// provider->chat(messages, tools, config, dispatcher, [](Result r) { +// provider->chat(messages, tools, config, dispatcher, [](Result +// r) { // if (r.isOk()) { // auto response = r.value(); // // Handle response... @@ -73,7 +74,8 @@ class LLMProvider { // callback - Called with response or error // // The callback receives: - // - LLMResponse on success (may contain tool_calls if LLM wants to use tools) + // - 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, @@ -143,18 +145,13 @@ class LLMProvider { // ═══════════════════════════════════════════════════════════════════════════ // Provider types for factory -enum class ProviderType { - OPENAI, - ANTHROPIC, - OLLAMA, - CUSTOM -}; +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::string base_url; // Override default endpoint std::map headers; // Additional headers ProviderConfig() = default; @@ -170,7 +167,8 @@ struct ProviderConfig { return *this; } - ProviderConfig& withHeader(const std::string& name, const std::string& value) { + ProviderConfig& withHeader(const std::string& name, + const std::string& value) { headers[name] = value; return *this; } @@ -182,10 +180,11 @@ LLMProviderPtr createProvider(const ProviderConfig& config); // Convenience factory functions LLMProviderPtr createOpenAIProvider(const std::string& api_key, - const std::string& base_url = ""); + 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"); + const std::string& base_url = ""); +LLMProviderPtr createOllamaProvider( + const std::string& base_url = "http://localhost:11434"); } // namespace llm } // namespace orch diff --git a/include/gopher/orch/llm/llm_types.h b/include/gopher/orch/llm/llm_types.h index e51bdaf9..535eec6b 100644 --- a/include/gopher/orch/llm/llm_types.h +++ b/include/gopher/orch/llm/llm_types.h @@ -50,21 +50,26 @@ inline std::string roleToString(Role role) { // 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; + 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 + 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_, + ToolCall(const std::string& id_, + const std::string& name_, const JsonValue& args_) : id(id_), name(name_), arguments(args_) {} }; @@ -210,8 +215,9 @@ struct Usage { // ═══════════════════════════════════════════════════════════════════════════ struct LLMResponse { - Message message; // The response message - std::string finish_reason; // "stop", "tool_calls", "length", "content_filter" + Message message; // The response message + std::string + finish_reason; // "stop", "tool_calls", "length", "content_filter" optional usage; LLMResponse() = default; @@ -239,8 +245,8 @@ struct LLMResponse { // ═══════════════════════════════════════════════════════════════════════════ struct StreamDelta { - optional content; // Content chunk - optional tool_call; // Tool call chunk (partial) + optional content; // Content chunk + optional tool_call; // Tool call chunk (partial) optional finish_reason; }; diff --git a/include/gopher/orch/llm/openai_provider.h b/include/gopher/orch/llm/openai_provider.h index a9ab673a..70ef3449 100644 --- a/include/gopher/orch/llm/openai_provider.h +++ b/include/gopher/orch/llm/openai_provider.h @@ -8,7 +8,8 @@ // Usage: // auto provider = OpenAIProvider::create("sk-..."); // // Or with custom endpoint: -// auto provider = OpenAIProvider::create("sk-...", "https://custom.endpoint.com/v1"); +// auto provider = OpenAIProvider::create("sk-...", +// "https://custom.endpoint.com/v1"); // // LLMConfig config("gpt-4"); // provider->chat(messages, tools, config, dispatcher, callback); @@ -51,8 +52,9 @@ struct OpenAIConfig { return *this; } - OpenAIConfig& forAzure(const std::string& deployment, - const std::string& api_version = "2024-02-15-preview") { + 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; diff --git a/include/gopher/orch/orch.h b/include/gopher/orch/orch.h index 9dc5ae25..45deb66d 100644 --- a/include/gopher/orch/orch.h +++ b/include/gopher/orch/orch.h @@ -176,9 +176,9 @@ using server::ServerComposite; using server::ServerCompositePtr; using server::ServerPtr; using server::ServerTool; -using server::ServerToolPtr; using server::ServerToolInfo; using server::ServerToolListCallback; +using server::ServerToolPtr; using server::ToolMapping; // MCP Server and REST Server exports (conditional) @@ -239,19 +239,19 @@ using agent::ToolExecution; using agent::ToolFunction; using agent::ToolRegistry; using agent::ToolRegistryPtr; -using agent::toServerToolInfo; // Convert ToolSpec -> ServerToolInfo -using agent::toToolSpec; // Convert ServerToolInfo -> ToolSpec +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; -using agent::makeRESTToolAdapter; // FFI C++ utilities (conditional) // The C API (gopher_orch_*) is always available in the global namespace diff --git a/include/gopher/orch/server/mcp_server.h b/include/gopher/orch/server/mcp_server.h index 2557c1c3..36c1c8b4 100644 --- a/include/gopher/orch/server/mcp_server.h +++ b/include/gopher/orch/server/mcp_server.h @@ -126,7 +126,8 @@ class MCPServer : public Server { void disconnect(Dispatcher& dispatcher, std::function callback) override; - void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) override; + void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) override; JsonRunnablePtr tool(const std::string& name) override; diff --git a/include/gopher/orch/server/mock_server.h b/include/gopher/orch/server/mock_server.h index bfff9407..abbb2c5c 100644 --- a/include/gopher/orch/server/mock_server.h +++ b/include/gopher/orch/server/mock_server.h @@ -68,7 +68,8 @@ class MockServer : public Server { } } - void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) override { + void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) override { std::vector tools; { std::lock_guard lock(mutex_); diff --git a/include/gopher/orch/server/rest_server.h b/include/gopher/orch/server/rest_server.h index 69e67b7c..381c5b7f 100644 --- a/include/gopher/orch/server/rest_server.h +++ b/include/gopher/orch/server/rest_server.h @@ -92,8 +92,8 @@ inline HttpMethod parseHttpMethod(const std::string& method) { // Tool endpoint configuration struct RESTToolEndpoint { HttpMethod method = HttpMethod::GET; - std::string path; // e.g., "/users/{id}" - ServerToolInfo info; // Tool metadata + std::string path; // e.g., "/users/{id}" + ServerToolInfo info; // Tool metadata // Request body handling bool send_body = @@ -250,7 +250,8 @@ class RESTServer : public Server { void disconnect(Dispatcher& dispatcher, std::function callback) override; - void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) override; + void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) override; JsonRunnablePtr tool(const std::string& name) override; diff --git a/include/gopher/orch/server/server.h b/include/gopher/orch/server/server.h index 1e7f7a45..498fa9ca 100644 --- a/include/gopher/orch/server/server.h +++ b/include/gopher/orch/server/server.h @@ -53,7 +53,8 @@ enum class ConnectionState { // Callback types using ConnectionCallback = std::function)>; -using ServerToolListCallback = std::function>)>; +using ServerToolListCallback = + std::function>)>; // Server - Abstract interface for protocol-agnostic server access // @@ -89,7 +90,8 @@ class Server : public std::enable_shared_from_this { // List available tools (async) // May return cached list if already connected - virtual void listTools(Dispatcher& dispatcher, ServerToolListCallback callback) = 0; + virtual void listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) = 0; // Get a tool by name as a Runnable // Returns nullptr if tool not found diff --git a/src/gopher/orch/agent/agent.cc b/src/gopher/orch/agent/agent.cc index 7a2cbc05..472d8d81 100644 --- a/src/gopher/orch/agent/agent.cc +++ b/src/gopher/orch/agent/agent.cc @@ -91,39 +91,38 @@ class ReActAgent::Impl { // ═══════════════════════════════════════════════════════════════════════════ ReActAgent::Ptr ReActAgent::create(LLMProviderPtr provider, - ToolRegistryPtr tools, - const AgentConfig& config) { + 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) { + 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)) {} + : impl_(std::make_unique( + std::move(provider), std::move(tools), config)) {} -ReActAgent::~ReActAgent() { - cancel(); -} +ReActAgent::~ReActAgent() { cancel(); } // ═══════════════════════════════════════════════════════════════════════════ // RUN METHODS // ═══════════════════════════════════════════════════════════════════════════ void ReActAgent::run(const std::string& query, - Dispatcher& dispatcher, - AgentCallback callback) { + 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) { + const std::vector& context, + Dispatcher& dispatcher, + AgentCallback callback) { // Check if already running if (impl_->state.status == AgentStatus::RUNNING) { dispatcher.post([callback = std::move(callback)]() { @@ -177,9 +176,7 @@ void ReActAgent::cancel() { // STATE ACCESS // ═══════════════════════════════════════════════════════════════════════════ -const AgentState& ReActAgent::state() const { - return impl_->state; -} +const AgentState& ReActAgent::state() const { return impl_->state; } bool ReActAgent::isRunning() const { return impl_->state.status == AgentStatus::RUNNING; @@ -193,17 +190,11 @@ void ReActAgent::setToolApprovalCallback(ToolApprovalCallback callback) { impl_->approval_callback = std::move(callback); } -LLMProviderPtr ReActAgent::provider() const { - return impl_->provider; -} +LLMProviderPtr ReActAgent::provider() const { return impl_->provider; } -ToolRegistryPtr ReActAgent::tools() const { - return impl_->tools; -} +ToolRegistryPtr ReActAgent::tools() const { return impl_->tools; } -const AgentConfig& ReActAgent::config() const { - return impl_->config; -} +const AgentConfig& ReActAgent::config() const { return impl_->config; } void ReActAgent::setConfig(const AgentConfig& config) { if (impl_->state.status != AgentStatus::RUNNING) { @@ -212,9 +203,9 @@ void ReActAgent::setConfig(const AgentConfig& config) { } void ReActAgent::addTool(const std::string& name, - const std::string& description, - const JsonValue& parameters, - ToolFunction function) { + const std::string& description, + const JsonValue& parameters, + ToolFunction function) { if (impl_->tools) { impl_->tools->addTool(name, description, parameters, std::move(function)); } @@ -233,8 +224,8 @@ void ReActAgent::executeLoop(Dispatcher& dispatcher) { // Check iteration limit if (impl_->state.current_iteration >= impl_->config.max_iterations) { - impl_->state.error = Error(AgentError::MAX_ITERATIONS, - "Maximum iterations reached"); + impl_->state.error = + Error(AgentError::MAX_ITERATIONS, "Maximum iterations reached"); completeRun(AgentStatus::MAX_ITERATIONS_REACHED, dispatcher); return; } @@ -290,7 +281,7 @@ void ReActAgent::callLLM(Dispatcher& dispatcher) { } void ReActAgent::handleLLMResponse(const LLMResponse& response, - Dispatcher& dispatcher) { + Dispatcher& dispatcher) { // Add assistant message to history impl_->state.messages.push_back(response.message); @@ -305,14 +296,14 @@ void ReActAgent::handleLLMResponse(const LLMResponse& response, } void ReActAgent::executeToolCalls(const std::vector& calls, - Dispatcher& dispatcher) { + 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); + impl_->state.error = + Error(AgentError::CANCELLED, "Tool call rejected: " + call.name); completeRun(AgentStatus::CANCELLED, dispatcher); return; } @@ -335,8 +326,8 @@ void ReActAgent::executeToolCalls(const std::vector& calls, impl_->executor->executeToolCalls( calls, impl_->config.parallel_tool_calls, dispatcher, - [this, &dispatcher, calls, start_time]( - std::vector> results) { + [this, &dispatcher, calls, + start_time](std::vector> results) { auto duration = std::chrono::duration_cast( std::chrono::steady_clock::now() - start_time); @@ -344,9 +335,10 @@ void ReActAgent::executeToolCalls(const std::vector& calls, }); } -void ReActAgent::handleToolResults(const std::vector& calls, - const std::vector>& results, - Dispatcher& 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(); @@ -406,8 +398,8 @@ void ReActAgent::completeRun(AgentStatus status, Dispatcher& dispatcher) { if (status == AgentStatus::COMPLETED) { callback(Result(std::move(result))); } else { - callback(Result( - impl_->state.error.value_or(Error(AgentError::UNKNOWN, "Unknown error")))); + callback(Result(impl_->state.error.value_or( + Error(AgentError::UNKNOWN, "Unknown error")))); } } } diff --git a/src/gopher/orch/agent/config_loader.cc b/src/gopher/orch/agent/config_loader.cc index 7ff495ff..b0ef2209 100644 --- a/src/gopher/orch/agent/config_loader.cc +++ b/src/gopher/orch/agent/config_loader.cc @@ -32,10 +32,14 @@ VoidResult ConfigLoader::loadEnvFile(const std::string& path) { 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); + 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) { diff --git a/src/gopher/orch/agent/tool_registry.cc b/src/gopher/orch/agent/tool_registry.cc index 59d135df..3244beb9 100644 --- a/src/gopher/orch/agent/tool_registry.cc +++ b/src/gopher/orch/agent/tool_registry.cc @@ -1,6 +1,7 @@ // ToolRegistry Config Loading Implementation #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" @@ -18,8 +19,8 @@ namespace agent { // ═══════════════════════════════════════════════════════════════════════════ void ToolRegistry::loadFromFile(const std::string& path, - Dispatcher& dispatcher, - std::function callback) { + Dispatcher& dispatcher, + std::function callback) { ConfigLoader loader; // Copy env vars to loader @@ -32,9 +33,10 @@ void ToolRegistry::loadFromFile(const std::string& path, auto result = loader.loadFromFile(path); if (!mcp::holds_alternative(result)) { - dispatcher.post([callback = std::move(callback), err = mcp::get(result)]() { - callback(VoidResult(err)); - }); + dispatcher.post( + [callback = std::move(callback), err = mcp::get(result)]() { + callback(VoidResult(err)); + }); return; } @@ -42,8 +44,8 @@ void ToolRegistry::loadFromFile(const std::string& path, } void ToolRegistry::loadFromString(const std::string& json_string, - Dispatcher& dispatcher, - std::function callback) { + Dispatcher& dispatcher, + std::function callback) { ConfigLoader loader; { @@ -55,9 +57,10 @@ void ToolRegistry::loadFromString(const std::string& json_string, auto result = loader.loadFromString(json_string); if (!mcp::holds_alternative(result)) { - dispatcher.post([callback = std::move(callback), err = mcp::get(result)]() { - callback(VoidResult(err)); - }); + dispatcher.post( + [callback = std::move(callback), err = mcp::get(result)]() { + callback(VoidResult(err)); + }); return; } @@ -65,10 +68,11 @@ void ToolRegistry::loadFromString(const std::string& json_string, } void ToolRegistry::loadConfig(const RegistryConfig& config, - Dispatcher& dispatcher, - std::function callback) { + Dispatcher& dispatcher, + std::function callback) { // Track pending MCP server connections - auto pending = std::make_shared>(config.mcp_servers.size()); + auto pending = + std::make_shared>(config.mcp_servers.size()); auto errors = std::make_shared>(); auto self = this; auto config_copy = std::make_shared(config); @@ -102,24 +106,23 @@ void ToolRegistry::loadConfig(const RegistryConfig& config, // 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(); - } - }); + 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) { + Dispatcher& dispatcher) { // Create ToolEntry from definition ToolEntry entry; entry.spec = def.toToolSpec(); @@ -156,8 +159,9 @@ VoidResult ToolRegistry::registerTool(const ToolDefinition& def, 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)); + return VoidResult(Error( + -1, + "Tool has no handler, REST endpoint, or MCP reference: " + def.name)); } // Register the tool @@ -185,19 +189,25 @@ VoidResult ToolRegistry::loadEnvFile(const std::string& path) { std::string line; while (std::getline(file, line)) { - if (line.empty() || line[0] == '#') continue; + if (line.empty() || line[0] == '#') + continue; auto pos = line.find('='); - if (pos == std::string::npos) continue; + 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); + 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) { @@ -220,8 +230,8 @@ VoidResult ToolRegistry::loadEnvFile(const std::string& path) { // ═══════════════════════════════════════════════════════════════════════════ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, - Dispatcher& dispatcher, - std::function callback) { + Dispatcher& dispatcher, + std::function callback) { #ifdef GOPHER_ORCH_WITH_MCP using namespace gopher::orch::server; @@ -244,7 +254,8 @@ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, 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; + config.stdio_transport.working_directory = + def.stdio_config->working_directory; break; } @@ -278,31 +289,30 @@ void ToolRegistry::addMCPServer(const MCPServerDefinition& def, } // 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)); - }); + 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)"))); + callback(VoidResult(Error( + -1, "MCP support not compiled (GOPHER_ORCH_WITH_MCP not defined)"))); }); #endif } diff --git a/src/gopher/orch/llm/anthropic_provider.cc b/src/gopher/orch/llm/anthropic_provider.cc index fdb4a8d6..a33e38b2 100644 --- a/src/gopher/orch/llm/anthropic_provider.cc +++ b/src/gopher/orch/llm/anthropic_provider.cc @@ -42,7 +42,8 @@ class AnthropicProvider::Impl { if (!config.betas.empty()) { std::string beta_str; for (size_t i = 0; i < config.betas.size(); ++i) { - if (i > 0) beta_str += ","; + if (i > 0) + beta_str += ","; beta_str += config.betas[i]; } hdrs["anthropic-beta"] = beta_str; @@ -61,7 +62,7 @@ AnthropicProvider::Ptr AnthropicProvider::create(const std::string& api_key) { } AnthropicProvider::Ptr AnthropicProvider::create(const std::string& api_key, - const std::string& base_url) { + const std::string& base_url) { AnthropicConfig config(api_key); if (!base_url.empty()) { config.withBaseUrl(base_url); @@ -69,7 +70,8 @@ AnthropicProvider::Ptr AnthropicProvider::create(const std::string& api_key, return create(config); } -AnthropicProvider::Ptr AnthropicProvider::create(const AnthropicConfig& config) { +AnthropicProvider::Ptr AnthropicProvider::create( + const AnthropicConfig& config) { return Ptr(new AnthropicProvider(config)); } @@ -83,10 +85,10 @@ AnthropicProvider::~AnthropicProvider() = default; // ═══════════════════════════════════════════════════════════════════════════ void AnthropicProvider::chat(const std::vector& messages, - const std::vector& tools, - const LLMConfig& config, - Dispatcher& dispatcher, - ChatCallback callback) { + const std::vector& tools, + const LLMConfig& config, + Dispatcher& dispatcher, + ChatCallback callback) { auto request = buildRequest(messages, tools, config, false); auto request_body = request.toString(); @@ -103,7 +105,8 @@ void AnthropicProvider::chat(const std::vector& messages, auto& response = mcp::get(result); if (!response.isSuccess()) { - std::string error_msg = "HTTP " + std::to_string(response.status_code); + std::string error_msg = + "HTTP " + std::to_string(response.status_code); try { auto error_json = JsonValue::parse(response.body); if (error_json.contains("error") && @@ -140,11 +143,11 @@ void AnthropicProvider::chat(const std::vector& messages, } void AnthropicProvider::chatStream(const std::vector& messages, - const std::vector& tools, - const LLMConfig& config, - Dispatcher& dispatcher, - StreamCallback on_chunk, - ChatCallback on_complete) { + 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)); } @@ -154,16 +157,17 @@ void AnthropicProvider::chatStream(const std::vector& messages, // ═══════════════════════════════════════════════════════════════════════════ JsonValue AnthropicProvider::buildRequest(const std::vector& messages, - const std::vector& tools, - const LLMConfig& config, - bool stream) const { + 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); + auto [system_prompt, anthropic_messages] = + messagesToAnthropicFormat(messages); if (!system_prompt.empty()) { request["system"] = system_prompt; @@ -288,7 +292,8 @@ std::pair AnthropicProvider::messagesToAnthropicFormat( return {system_prompt, anthropic_messages}; } -Result AnthropicProvider::parseResponse(const JsonValue& response) const { +Result AnthropicProvider::parseResponse( + const JsonValue& response) const { LLMResponse result; try { @@ -317,7 +322,8 @@ Result AnthropicProvider::parseResponse(const JsonValue& response) 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() : ""; + std::string block_type = + block.contains("type") ? block["type"].getString() : ""; if (block_type == "text") { if (!text_content.empty()) { @@ -344,8 +350,10 @@ Result AnthropicProvider::parseResponse(const JsonValue& response) 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.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; } @@ -376,17 +384,11 @@ bool AnthropicProvider::isModelSupported(const std::string& model) const { } 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" - }; + 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"}; } // ═══════════════════════════════════════════════════════════════════════════ @@ -406,7 +408,7 @@ bool AnthropicProvider::isConfigured() const { // ═══════════════════════════════════════════════════════════════════════════ LLMProviderPtr createAnthropicProvider(const std::string& api_key, - const std::string& base_url) { + const std::string& base_url) { if (base_url.empty()) { return AnthropicProvider::create(api_key); } diff --git a/src/gopher/orch/llm/llm_factory.cc b/src/gopher/orch/llm/llm_factory.cc index 54954c04..2eee8c39 100644 --- a/src/gopher/orch/llm/llm_factory.cc +++ b/src/gopher/orch/llm/llm_factory.cc @@ -1,8 +1,8 @@ // 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" -#include "gopher/orch/llm/anthropic_provider.h" namespace gopher { namespace orch { @@ -29,8 +29,9 @@ LLMProviderPtr createProvider(const ProviderConfig& 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); + ollama_config.withBaseUrl(config.base_url.empty() + ? "http://localhost:11434/v1" + : config.base_url); return OpenAIProvider::create(ollama_config); } diff --git a/src/gopher/orch/llm/openai_provider.cc b/src/gopher/orch/llm/openai_provider.cc index 98156b10..c715eb2b 100644 --- a/src/gopher/orch/llm/openai_provider.cc +++ b/src/gopher/orch/llm/openai_provider.cc @@ -31,7 +31,8 @@ class OpenAIProvider::Impl { std::string chatEndpoint() const { if (config.is_azure) { - return config.base_url + "/openai/deployments/" + config.azure_deployment + + return config.base_url + "/openai/deployments/" + + config.azure_deployment + "/chat/completions?api-version=" + config.azure_api_version; } return config.base_url + "/chat/completions"; @@ -63,7 +64,7 @@ OpenAIProvider::Ptr OpenAIProvider::create(const std::string& api_key) { } OpenAIProvider::Ptr OpenAIProvider::create(const std::string& api_key, - const std::string& base_url) { + const std::string& base_url) { OpenAIConfig config(api_key); if (!base_url.empty()) { config.withBaseUrl(base_url); @@ -108,7 +109,8 @@ void OpenAIProvider::chat(const std::vector& messages, auto& response = mcp::get(result); if (!response.isSuccess()) { // Parse error response - std::string error_msg = "HTTP " + std::to_string(response.status_code); + std::string error_msg = + "HTTP " + std::to_string(response.status_code); try { auto error_json = JsonValue::parse(response.body); if (error_json.contains("error") && @@ -139,17 +141,18 @@ void OpenAIProvider::chat(const std::vector& messages, callback(std::move(parsed)); } catch (const std::exception& e) { callback(Result( - Error(LLMError::PARSE_ERROR, std::string("Failed to parse response: ") + e.what()))); + 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) { + 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)); @@ -160,9 +163,9 @@ void OpenAIProvider::chatStream(const std::vector& messages, // ═══════════════════════════════════════════════════════════════════════════ JsonValue OpenAIProvider::buildRequest(const std::vector& messages, - const std::vector& tools, - const LLMConfig& config, - bool stream) const { + const std::vector& tools, + const LLMConfig& config, + bool stream) const { JsonValue request = JsonValue::object(); // Model @@ -212,7 +215,8 @@ JsonValue OpenAIProvider::buildRequest(const std::vector& messages, return request; } -Result OpenAIProvider::parseResponse(const JsonValue& response) const { +Result OpenAIProvider::parseResponse( + const JsonValue& response) const { LLMResponse result; try { @@ -276,9 +280,13 @@ Result OpenAIProvider::parseResponse(const JsonValue& response) con 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; + 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; } @@ -344,7 +352,8 @@ JsonValue OpenAIProvider::toolToJson(const ToolSpec& tool) const { return json; } -Result OpenAIProvider::parseStreamChunk(const std::string& data) const { +Result OpenAIProvider::parseStreamChunk( + const std::string& data) const { // SSE data parsing would go here // For now, return empty chunk StreamChunk chunk; @@ -362,25 +371,15 @@ bool OpenAIProvider::isModelSupported(const std::string& model) const { } 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" - }; + 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(); -} +std::string OpenAIProvider::endpoint() const { return impl_->chatEndpoint(); } bool OpenAIProvider::isConfigured() const { return !impl_->config.api_key.empty(); @@ -401,7 +400,7 @@ void OpenAIProvider::setOrganization(const std::string& org) { // ═══════════════════════════════════════════════════════════════════════════ LLMProviderPtr createOpenAIProvider(const std::string& api_key, - const std::string& base_url) { + const std::string& base_url) { if (base_url.empty()) { return OpenAIProvider::create(api_key); } diff --git a/src/gopher/orch/server/mcp_server.cc b/src/gopher/orch/server/mcp_server.cc index cad8d2cb..a4d140ae 100644 --- a/src/gopher/orch/server/mcp_server.cc +++ b/src/gopher/orch/server/mcp_server.cc @@ -358,11 +358,12 @@ void MCPServer::disconnect(Dispatcher& dispatcher, } // List available tools -void MCPServer::listTools(Dispatcher& dispatcher, ServerToolListCallback callback) { +void MCPServer::listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) { if (!this->Server::isConnected()) { dispatcher.post([callback]() { - callback(makeOrchError>(OrchError::NOT_CONNECTED, - "Server is not connected")); + callback(makeOrchError>( + OrchError::NOT_CONNECTED, "Server is not connected")); }); return; } @@ -386,8 +387,8 @@ void MCPServer::listTools(Dispatcher& dispatcher, ServerToolListCallback callbac self->onToolsListed(tools_result); callback(makeSuccess(self->tools_)); } catch (const std::exception& e) { - callback(makeOrchError>(OrchError::INTERNAL_ERROR, - e.what())); + callback(makeOrchError>( + OrchError::INTERNAL_ERROR, e.what())); } }); } diff --git a/src/gopher/orch/server/rest_server.cc b/src/gopher/orch/server/rest_server.cc index a0c061c8..bbb560f5 100644 --- a/src/gopher/orch/server/rest_server.cc +++ b/src/gopher/orch/server/rest_server.cc @@ -224,7 +224,8 @@ void RESTServer::disconnect(Dispatcher& dispatcher, } } -void RESTServer::listTools(Dispatcher& dispatcher, ServerToolListCallback callback) { +void RESTServer::listTools(Dispatcher& dispatcher, + ServerToolListCallback callback) { std::vector tools; tools.reserve(config_.tools.size()); diff --git a/tests/gopher/orch/agent_test.cc b/tests/gopher/orch/agent_test.cc index 36b85c61..9123f752 100644 --- a/tests/gopher/orch/agent_test.cc +++ b/tests/gopher/orch/agent_test.cc @@ -1,11 +1,11 @@ // Unit tests for ReActAgent -#include "orch_test_fixture.h" -#include "mock_llm_provider.h" - #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; @@ -34,7 +34,8 @@ class AgentTest : public OrchTest { } // Helper to run agent and allow errors - Result runAgentResult(ReActAgent::Ptr agent, const std::string& query) { + Result runAgentResult(ReActAgent::Ptr agent, + const std::string& query) { return runToCompletionResult( [&](Dispatcher& d, ResultCallback cb) { agent->run(query, d, std::move(cb)); @@ -57,8 +58,8 @@ TEST_F(AgentTest, CreateAgent) { TEST_F(AgentTest, CreateAgentWithConfig) { AgentConfig config("gpt-4"); config.withSystemPrompt("You are a helpful assistant.") - .withMaxIterations(5) - .withTemperature(0.7); + .withMaxIterations(5) + .withTemperature(0.7); auto agent = ReActAgent::create(provider_, registry_, config); @@ -143,21 +144,19 @@ TEST_F(AgentTest, MultipleToolCalls) { 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); - }); + 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?"); @@ -184,17 +183,15 @@ TEST_F(AgentTest, ChainedToolCalls) { // 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_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")); - }); + 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"); @@ -218,7 +215,8 @@ TEST_F(AgentTest, ToolNotFound) { // 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) { + if (msg.role == Role::TOOL && + msg.content.find("not found") != std::string::npos) { found_error_message = true; break; } @@ -257,11 +255,10 @@ TEST_F(AgentTest, MaxIterationsReached) { 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")); - }); + registry_->addSyncTool("loop_tool", "Loop forever", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("looping")); + }); AgentConfig config("test-model"); config.withMaxIterations(3); @@ -288,11 +285,10 @@ TEST_F(AgentTest, StepCallback) { provider_->queueToolCalls({call1}); provider_->queueResponse("Final answer"); - registry_->addSyncTool( - "test_tool", "Test", JsonValue::object(), - [](const JsonValue& args) -> Result { - return Result(JsonValue("result")); - }); + registry_->addSyncTool("test_tool", "Test", JsonValue::object(), + [](const JsonValue& args) -> Result { + return Result(JsonValue("result")); + }); std::vector step_numbers; @@ -314,17 +310,15 @@ TEST_F(AgentTest, ToolApprovalCallback) { 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("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")); - }); + 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) { @@ -347,9 +341,8 @@ TEST_F(AgentTest, ToolApprovalCallback) { TEST_F(AgentTest, RunWithContext) { provider_->setDefaultResponse("I remember the context."); - std::vector context = { - Message::user("My name is Alice"), - Message::assistant("Hello Alice!")}; + std::vector context = {Message::user("My name is Alice"), + Message::assistant("Hello Alice!")}; auto agent = ReActAgent::create(provider_, registry_); @@ -421,11 +414,11 @@ TEST(AgentTypesTest, AgentStatusToString) { 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); + .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"); diff --git a/tests/gopher/orch/llm_provider_test.cc b/tests/gopher/orch/llm_provider_test.cc index 630f536a..57b515fb 100644 --- a/tests/gopher/orch/llm_provider_test.cc +++ b/tests/gopher/orch/llm_provider_test.cc @@ -1,11 +1,10 @@ // Unit tests for LLM Providers (OpenAI, Anthropic) -#include "orch_test_fixture.h" +#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 "gopher/orch/llm/openai_provider.h" -#include "gopher/orch/llm/anthropic_provider.h" +#include "orch_test_fixture.h" using namespace gopher::orch::llm; @@ -109,17 +108,17 @@ 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")}; + 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)); - }); + 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); @@ -139,9 +138,10 @@ TEST_F(MockLLMProviderTest, Reset) { std::vector messages = {Message::user("Hi")}; LLMConfig config("test-model"); - runToCompletion([&](Dispatcher& d, ResultCallback cb) { - provider_->chat(messages, {}, config, d, std::move(cb)); - }); + runToCompletion( + [&](Dispatcher& d, ResultCallback cb) { + provider_->chat(messages, {}, config, d, std::move(cb)); + }); EXPECT_EQ(provider_->callCount(), 1u); EXPECT_FALSE(provider_->lastMessages().empty()); @@ -205,11 +205,11 @@ TEST(LLMTypesTest, RoleConversion) { 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)); + .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()); @@ -274,8 +274,8 @@ TEST(LLMTypesTest, ToolSpec) { TEST(ProviderConfigTest, Builder) { ProviderConfig config(ProviderType::OPENAI); config.withApiKey("sk-test") - .withBaseUrl("https://custom.api.com") - .withHeader("X-Custom", "value"); + .withBaseUrl("https://custom.api.com") + .withHeader("X-Custom", "value"); EXPECT_EQ(config.type, ProviderType::OPENAI); EXPECT_EQ(config.api_key, "sk-test"); diff --git a/tests/gopher/orch/mock_http_client.h b/tests/gopher/orch/mock_http_client.h index 1c5180db..62d1dded 100644 --- a/tests/gopher/orch/mock_http_client.h +++ b/tests/gopher/orch/mock_http_client.h @@ -84,17 +84,18 @@ class MockHttpClient : public HttpClient { // 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))); - } - }); + 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 { + dispatcher.post([callback = std::move(callback), + response_config]() mutable { if (response_config.error.has_value()) { callback(Result(*response_config.error)); } else { @@ -110,9 +111,9 @@ class MockHttpClient : public HttpClient { // Set response for a specific URL/method MockHttpClient& setResponse(HttpMethod method, - const std::string& url, - int status_code, - const std::string& body) { + const std::string& url, + int status_code, + const std::string& body) { std::lock_guard lock(mutex_); std::string key = httpMethodToString(method) + " " + url; MockHttpResponseConfig config; @@ -123,11 +124,12 @@ class MockHttpClient : public HttpClient { } // Set response with headers - MockHttpClient& setResponse(HttpMethod method, - const std::string& url, - int status_code, - const std::string& body, - const std::map& 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; @@ -140,9 +142,9 @@ class MockHttpClient : public HttpClient { // Set error for a specific URL/method MockHttpClient& setError(HttpMethod method, - const std::string& url, - int code, - const std::string& message) { + const std::string& url, + int code, + const std::string& message) { std::lock_guard lock(mutex_); std::string key = httpMethodToString(method) + " " + url; MockHttpResponseConfig config; @@ -163,8 +165,8 @@ class MockHttpClient : public HttpClient { // Set response delay MockHttpClient& setDelay(HttpMethod method, - const std::string& url, - std::chrono::milliseconds delay) { + 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()) { diff --git a/tests/gopher/orch/mock_llm_provider.h b/tests/gopher/orch/mock_llm_provider.h index c9ff8201..ff0fa33b 100644 --- a/tests/gopher/orch/mock_llm_provider.h +++ b/tests/gopher/orch/mock_llm_provider.h @@ -59,23 +59,25 @@ class MockLLMProvider : public LLMProvider { response_config.response = *default_response_; } else { // Default: return empty response - response_config.response.message = Message::assistant("Default mock 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))); - } - }); + 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 { + dispatcher.post([callback = std::move(callback), + response_config]() mutable { if (response_config.error.has_value()) { callback(Result(*response_config.error)); } else { @@ -103,13 +105,9 @@ class MockLLMProvider : public LLMProvider { return {"mock-model", "test-model"}; } - std::string endpoint() const override { - return "mock://localhost/v1/chat"; - } + std::string endpoint() const override { return "mock://localhost/v1/chat"; } - bool isConfigured() const override { - return true; - } + bool isConfigured() const override { return true; } // ========================================================================= // MockLLMProvider-specific API for test configuration @@ -126,7 +124,8 @@ class MockLLMProvider : public LLMProvider { } // Set default response with tool calls - MockLLMProvider& setDefaultToolCalls(const std::vector& tool_calls) { + MockLLMProvider& setDefaultToolCalls( + const std::vector& tool_calls) { std::lock_guard lock(mutex_); LLMResponse response; response.message = Message::assistantWithToolCalls(tool_calls); diff --git a/tests/gopher/orch/server_composite_test.cc b/tests/gopher/orch/server_composite_test.cc index 18f54671..07095fcb 100644 --- a/tests/gopher/orch/server_composite_test.cc +++ b/tests/gopher/orch/server_composite_test.cc @@ -84,8 +84,8 @@ TEST_F(OrchTest, ServerCompositeAliases) { // 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 + {"get_data", "internal_get_data_v2"}, {"fetch", "internal_get_data_v2"} + // Multiple aliases for same tool }; composite->addServerWithAliases(server, aliases); diff --git a/tests/gopher/orch/tool_registry_test.cc b/tests/gopher/orch/tool_registry_test.cc index 6657a6d6..9b9a877b 100644 --- a/tests/gopher/orch/tool_registry_test.cc +++ b/tests/gopher/orch/tool_registry_test.cc @@ -1,12 +1,12 @@ // Unit tests for ToolRegistry and ToolExecutor -#include "orch_test_fixture.h" - #include "gopher/orch/agent/tool_registry.h" -#include "gopher/orch/agent/tool_executor.h" -#include "gopher/orch/agent/tool_definition.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; @@ -65,11 +65,10 @@ TEST_F(ToolRegistryTest, CreateEmpty) { } TEST_F(ToolRegistryTest, AddLocalTool) { - registry_->addTool( - "calculator", "Perform calculations", makeSchema(), - [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { - cb(Result(JsonValue(42))); - }); + 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")); @@ -83,9 +82,10 @@ TEST_F(ToolRegistryTest, AddLocalTool) { 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"))); - }); + registry_->addTool(spec, + [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { + cb(Result(JsonValue("search result"))); + }); EXPECT_TRUE(registry_->hasTool("search")); @@ -96,18 +96,16 @@ TEST_F(ToolRegistryTest, AddToolWithSpec) { } TEST_F(ToolRegistryTest, AddSyncTool) { - registry_->addSyncTool( - "sync_calc", "Synchronous calculation", makeSchema(), - [](const JsonValue& args) -> Result { - return Result(JsonValue(100)); - }); + 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)); - }); + auto result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("sync_calc", JsonValue::object(), d, std::move(cb)); + }); EXPECT_EQ(result.getInt(), 100); } @@ -142,30 +140,29 @@ TEST_F(ToolRegistryTest, AddMultipleTools) { // ============================================================================= 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))); - }); + 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)); - }); + 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)); + auto result = + runToCompletionResult([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("nonexistent", JsonValue::object(), d, + std::move(cb)); }); EXPECT_TRUE(mcp::holds_alternative(result)); @@ -180,61 +177,58 @@ TEST_F(ToolRegistryTest, ExecuteToolWithError) { return Result(Error(-1, "Intentional failure")); }); - auto result = runToCompletionResult( - [&](Dispatcher& d, JsonCallback cb) { - executor_->executeTool("failing", JsonValue::object(), d, std::move(cb)); - }); + 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); - }); + 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)); - }); + 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("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)); - }); + 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 calls = {ToolCall("call_1", "double", args1), + ToolCall("call_2", "triple", args2)}; std::vector> results; @@ -242,19 +236,19 @@ TEST_F(ToolRegistryTest, ExecuteMultipleToolCalls) { 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(); - }); + 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; + if (done) + break; } dispatcher_->run(mcp::event::RunType::NonBlock); std::this_thread::sleep_for(std::chrono::milliseconds(1)); @@ -263,8 +257,8 @@ TEST_F(ToolRegistryTest, ExecuteMultipleToolCalls) { 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 + EXPECT_EQ(mcp::get(results[0]).getInt(), 10); // 5 * 2 + EXPECT_EQ(mcp::get(results[1]).getInt(), 30); // 10 * 3 } // ============================================================================= @@ -315,10 +309,10 @@ TEST_F(ToolRegistryTest, ExecuteServerTool) { registry_->addServer(mock_server_, tools); - auto result = runToCompletion( - [&](Dispatcher& d, JsonCallback cb) { - executor_->executeTool("remote_calc", JsonValue::object(), d, std::move(cb)); - }); + 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); @@ -338,10 +332,10 @@ TEST_F(ToolRegistryTest, AddServerToolWithAlias) { 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)); - }); + auto result = runToCompletion([&](Dispatcher& d, JsonCallback cb) { + executor_->executeTool("aliased_name", JsonValue::object(), d, + std::move(cb)); + }); EXPECT_EQ(result.getString(), "ok"); } @@ -579,7 +573,8 @@ TEST_F(ConfigLoaderTest, ParseHTTPSSEServer) { 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_EQ(def.http_sse_config->headers["Authorization"], + "Bearer test-key-123"); EXPECT_FALSE(def.http_sse_config->verify_ssl); } @@ -741,10 +736,10 @@ TEST_F(ToolExecutorTest, CreateExecutor) { 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)); - }); + 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); @@ -757,10 +752,10 @@ TEST_F(ToolExecutorTest, ExecuteEmptyToolCalls) { bool done = false; executor_->executeToolCalls(empty_calls, true, *dispatcher_, - [&](std::vector> r) { - results = std::move(r); - done = true; - }); + [&](std::vector> r) { + results = std::move(r); + done = true; + }); while (!done) { dispatcher_->run(mcp::event::RunType::NonBlock); From 5e4558ac83837da4aafdb097bb18248ebeb1563d Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 11:43:47 -0800 Subject: [PATCH 148/197] Add missing sstream include in rest_tool_adapter.h (#24) Fix compilation error for std::istringstream by adding the required #include header. --- include/gopher/orch/agent/rest_tool_adapter.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h index 2b4902d6..c10db6ae 100644 --- a/include/gopher/orch/agent/rest_tool_adapter.h +++ b/include/gopher/orch/agent/rest_tool_adapter.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include "gopher/orch/agent/tool_definition.h" From 3bb92b2c5047f5d7347e084b8925ff38636752a6 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 12:43:38 -0800 Subject: [PATCH 149/197] Update design docs for ToolExecutor separation (#24) Agent.md: - Update architecture diagram to show ToolExecutor instead of ToolRegistry - Change executeTool reference from registry to executor ToolRegistry.md: - Add mcp_reference example in JSON configuration schema --- docs/Agent.md | 8 ++++---- docs/ToolRegistry.md | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/Agent.md b/docs/Agent.md index a7554fc2..6e85e1f1 100644 --- a/docs/Agent.md +++ b/docs/Agent.md @@ -16,10 +16,10 @@ The Agent module implements the ReAct (Reasoning + Acting) pattern for building │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ LLMProvider │ │ ToolRegistry │ │ AgentState │ │ +│ │ LLMProvider │ │ ToolExecutor │ │ AgentState │ │ │ │ │ │ │ │ │ │ -│ │ • chat() │ │ • getToolSpecs()│ │ • messages │ │ -│ │ • toolCalls │ │ • executeTool() │ │ • steps │ │ +│ │ • chat() │ │ • executeTool() │ │ • messages │ │ +│ │ • toolCalls │ │ • registry() │ │ • steps │ │ │ └─────────────────┘ └─────────────────┘ │ • status │ │ │ └─────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ @@ -211,7 +211,7 @@ using ToolApprovalCallback = std::function; ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │ 5a. executeToolCalls() │ │ 5b. completeRun(COMPLETED) │ │ • Check approval callback │ │ • Set status │ -│ • Call registry.executeTool │ │ • Build AgentResult │ +│ • Call executor.executeTool │ │ • Build AgentResult │ │ for each tool │ │ • Invoke completion callback│ │ • Collect results │ └─────────────────────────────────┘ └─────────────────────────────────┘ diff --git a/docs/ToolRegistry.md b/docs/ToolRegistry.md index 3eec217d..459a7543 100644 --- a/docs/ToolRegistry.md +++ b/docs/ToolRegistry.md @@ -436,6 +436,21 @@ registry->loadFromFile("tools.json", dispatcher, "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" + } } ] } From e996bc72ef5edfeb58e774339748d02b2c6a723c Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 15:36:46 -0800 Subject: [PATCH 150/197] Rename RESTEndpointToolDef to RESTEndpoint and ToolDef to MCPToolRef (#24) --- include/gopher/orch/agent/tool_definition.h | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/include/gopher/orch/agent/tool_definition.h b/include/gopher/orch/agent/tool_definition.h index 621dced7..49e1b6c7 100644 --- a/include/gopher/orch/agent/tool_definition.h +++ b/include/gopher/orch/agent/tool_definition.h @@ -39,7 +39,7 @@ struct ToolDefinition { // ───────────────────────────────────────────────────────────────────────── // Option 1: REST Endpoint // ───────────────────────────────────────────────────────────────────────── - struct RESTEndpointToolDef { + struct RESTEndpoint { HttpMethod method = HttpMethod::GET; std::string url; // Full URL or path (supports ${ENV_VAR}) std::map headers; @@ -52,22 +52,22 @@ struct ToolDefinition { // Response extraction std::string response_path; // JSONPath to extract from response - RESTEndpointToolDef() = default; + RESTEndpoint() = default; }; - optional rest_endpoint; + optional rest_endpoint; // ───────────────────────────────────────────────────────────────────────── // Option 2: MCP Server Reference // ───────────────────────────────────────────────────────────────────────── - struct ToolDef { + struct MCPToolRef { std::string server_name; // Name of registered MCP server std::string tool_name; // Tool name on that server - ToolDef() = default; - ToolDef(const std::string& server, const std::string& tool) + MCPToolRef() = default; + MCPToolRef(const std::string& server, const std::string& tool) : server_name(server), tool_name(tool) {} }; - optional mcp_reference; + optional mcp_reference; // ───────────────────────────────────────────────────────────────────────── // Option 3: Lambda/Function (programmatic only) @@ -98,14 +98,14 @@ struct ToolDefinition { return *this; } - ToolDefinition& withRESTEndpoint(const RESTEndpointToolDef& ep) { + ToolDefinition& withRESTEndpoint(const RESTEndpoint& ep) { rest_endpoint = ep; return *this; } ToolDefinition& withMCPReference(const std::string& server, const std::string& tool) { - mcp_reference = ToolDef(server, tool); + mcp_reference = MCPToolRef(server, tool); return *this; } From 831d3ec0f8a1f1e05382616205ae2c21c7613513 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 15:36:58 -0800 Subject: [PATCH 151/197] Update config_loader.h for renamed tool types (#24) --- include/gopher/orch/agent/config_loader.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/gopher/orch/agent/config_loader.h b/include/gopher/orch/agent/config_loader.h index 5067b523..ff19ca86 100644 --- a/include/gopher/orch/agent/config_loader.h +++ b/include/gopher/orch/agent/config_loader.h @@ -295,7 +295,7 @@ inline Result ConfigLoader::parseToolDefinition( // Parse REST endpoint if (json.contains("rest_endpoint")) { const auto& ep = json["rest_endpoint"]; - ToolDefinition::RESTEndpointToolDef rest; + ToolDefinition::RESTEndpoint rest; rest.method = parseHttpMethod( ep.contains("method") ? ep["method"].getString() : "GET"); @@ -341,7 +341,7 @@ inline Result ConfigLoader::parseToolDefinition( // Parse MCP reference if (json.contains("mcp_reference")) { const auto& ref = json["mcp_reference"]; - ToolDefinition::ToolDef mcp; + ToolDefinition::MCPToolRef mcp; mcp.server_name = ref.contains("server_name") ? ref["server_name"].getString() : ""; mcp.tool_name = From c9d4628f6ada8b3a22386e2703df93a5f0ddd2b2 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 15:37:07 -0800 Subject: [PATCH 152/197] Update rest_tool_adapter.h for renamed RESTEndpoint type (#24) --- include/gopher/orch/agent/rest_tool_adapter.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h index c10db6ae..aaf45e45 100644 --- a/include/gopher/orch/agent/rest_tool_adapter.h +++ b/include/gopher/orch/agent/rest_tool_adapter.h @@ -2,7 +2,7 @@ // RESTToolAdapter - Create tools from REST endpoint definitions // -// Converts ToolDefinition with RESTEndpointToolDef to executable tools. +// Converts ToolDefinition with RESTEndpoint to executable tools. // Supports: // - Path parameter substitution (/users/{id}) // - Query parameter mapping ($.field) @@ -153,7 +153,7 @@ class RESTToolAdapter { } // Execute a REST call directly - void executeRESTCall(const ToolDefinition::RESTEndpointToolDef& endpoint, + void executeRESTCall(const ToolDefinition::RESTEndpoint& endpoint, const JsonValue& input, Dispatcher& dispatcher, JsonCallback callback) { From f1a6cd1f969c490a6a8e1a41703725336f1ca2a2 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 15:37:18 -0800 Subject: [PATCH 153/197] Update tool_registry_test.cc for renamed tool types (#24) --- tests/gopher/orch/tool_registry_test.cc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/gopher/orch/tool_registry_test.cc b/tests/gopher/orch/tool_registry_test.cc index 9b9a877b..a6b29da2 100644 --- a/tests/gopher/orch/tool_registry_test.cc +++ b/tests/gopher/orch/tool_registry_test.cc @@ -659,8 +659,8 @@ TEST(ToolDefinitionTest, ToToolSpec) { EXPECT_TRUE(spec.parameters.contains("type")); } -TEST(ToolDefinitionTest, RESTEndpointToolDef) { - ToolDefinition::RESTEndpointToolDef rest; +TEST(ToolDefinitionTest, RESTEndpoint) { + ToolDefinition::RESTEndpoint rest; rest.method = HttpMethod::POST; rest.url = "https://api.example.com/search"; rest.headers["Content-Type"] = "application/json"; @@ -671,8 +671,8 @@ TEST(ToolDefinitionTest, RESTEndpointToolDef) { EXPECT_EQ(rest.headers["Content-Type"], "application/json"); } -TEST(ToolDefinitionTest, ToolDef) { - ToolDefinition::ToolDef ref; +TEST(ToolDefinitionTest, MCPToolRef) { + ToolDefinition::MCPToolRef ref; ref.server_name = "mcp-server"; ref.tool_name = "remote_tool"; From 4c44e76127e40eef88c05411bcbb32ce68100b79 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 21:52:31 -0800 Subject: [PATCH 154/197] Add agent runnable design V1 (#27) --- docs/AgentRunnable.md | 660 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/AgentRunnable.md diff --git a/docs/AgentRunnable.md b/docs/AgentRunnable.md new file mode 100644 index 00000000..dd3fb07b --- /dev/null +++ b/docs/AgentRunnable.md @@ -0,0 +1,660 @@ +# 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 and LangGraph 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 | + +## 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` +- gopher-orch Runnable: `include/gopher/orch/core/runnable.h` +- gopher-orch Agent: `include/gopher/orch/agent/agent.h` From 6108bee954bde439a2fee5fbd1e3691421d6e89e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Thu, 1 Jan 2026 22:52:24 -0800 Subject: [PATCH 155/197] Add agent runnable design V2 (#27) --- docs/AgentRunnable.md | 205 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 1 deletion(-) diff --git a/docs/AgentRunnable.md b/docs/AgentRunnable.md index dd3fb07b..f82e006d 100644 --- a/docs/AgentRunnable.md +++ b/docs/AgentRunnable.md @@ -4,7 +4,7 @@ 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 and LangGraph patterns, adapted for C++ with async-first, dispatcher-based execution. +The design is inspired by LangChain, LangGraph, and n8n patterns, adapted for C++ with async-first, dispatcher-based execution. ## Goals @@ -629,6 +629,205 @@ include/gopher/orch/ | 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 @@ -656,5 +855,9 @@ class AgentRunnable : public Runnable { - 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` From 4c19348908ff7efd28bc1cbc71898a3ea4cfb7c1 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:14:35 -0800 Subject: [PATCH 156/197] Add LLMRunnable header (#27) Wraps LLMProvider as Runnable interface. Provides JSON-based I/O for composable LLM operations. --- include/gopher/orch/llm/llm_runnable.h | 120 +++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 include/gopher/orch/llm/llm_runnable.h diff --git a/include/gopher/orch/llm/llm_runnable.h b/include/gopher/orch/llm/llm_runnable.h new file mode 100644 index 00000000..b7e8e82a --- /dev/null +++ b/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 From 148652fab4607f1aa84b5d54d289dd0898ab5450 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:16:52 -0800 Subject: [PATCH 157/197] Add LLMRunnable implementation (#27) Implements input parsing, LLM invocation, and output conversion. Supports string input, messages array, tools, and config overrides. --- src/gopher/orch/llm/llm_runnable.cc | 248 ++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 src/gopher/orch/llm/llm_runnable.cc diff --git a/src/gopher/orch/llm/llm_runnable.cc b/src/gopher/orch/llm/llm_runnable.cc new file mode 100644 index 00000000..cfdcf52e --- /dev/null +++ b/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 From 632dadf8a3eb7b9e99c1ae8590dda90740055c6e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:17:32 -0800 Subject: [PATCH 158/197] Add LLMRunnable unit tests (#27) Tests for string input, messages array, tools, config overrides, tool call responses, usage tracking, and error propagation. --- tests/gopher/orch/llm_runnable_test.cc | 335 +++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 tests/gopher/orch/llm_runnable_test.cc diff --git a/tests/gopher/orch/llm_runnable_test.cc b/tests/gopher/orch/llm_runnable_test.cc new file mode 100644 index 00000000..9db935e2 --- /dev/null +++ b/tests/gopher/orch/llm_runnable_test.cc @@ -0,0 +1,335 @@ +// 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"); +} From 210421ecc65c9169896e8773daca0ead24112560 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:23:53 -0800 Subject: [PATCH 159/197] Add ToolRunnable header (#27) Wraps ToolExecutor as Runnable interface. Supports single and multiple (parallel) tool call execution. --- include/gopher/orch/agent/tool_runnable.h | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 include/gopher/orch/agent/tool_runnable.h diff --git a/include/gopher/orch/agent/tool_runnable.h b/include/gopher/orch/agent/tool_runnable.h new file mode 100644 index 00000000..d826bf3e --- /dev/null +++ b/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 From 64dd6d03d0d473bf1ab93219e5f2c76e819f00b3 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:24:11 -0800 Subject: [PATCH 160/197] Add ToolRunnable implementation (#27) Implements single tool call and batch tool call execution. Converts JSON input to ToolCall objects and results back to JSON. --- src/gopher/orch/agent/tool_runnable.cc | 213 +++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/gopher/orch/agent/tool_runnable.cc diff --git a/src/gopher/orch/agent/tool_runnable.cc b/src/gopher/orch/agent/tool_runnable.cc new file mode 100644 index 00000000..851825ab --- /dev/null +++ b/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 From 15cca803de7339347667ba8659b0889e32e7957d Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:24:24 -0800 Subject: [PATCH 161/197] Add ToolRunnable unit tests (#27) Tests for single tool calls, multiple parallel calls, error handling, auto-generated IDs, and factory functions. --- tests/gopher/orch/tool_runnable_test.cc | 390 ++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 tests/gopher/orch/tool_runnable_test.cc diff --git a/tests/gopher/orch/tool_runnable_test.cc b/tests/gopher/orch/tool_runnable_test.cc new file mode 100644 index 00000000..a86668c0 --- /dev/null +++ b/tests/gopher/orch/tool_runnable_test.cc @@ -0,0 +1,390 @@ +// 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_); +} From c5d28af353a2659690f11aacff602e1c9f91f01e Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:24:36 -0800 Subject: [PATCH 162/197] Add AgentState reducer and JSON serialization (#27) Add reduce() static method for merging states following LangGraph semantics: - Messages: APPEND - Steps: APPEND - Token usage: ACCUMULATE - Other fields: LAST-WRITE-WINS Add toJson() and fromJson() for graph node I/O. --- include/gopher/orch/agent/agent_types.h | 226 +++++++++++++++++++++++- 1 file changed, 221 insertions(+), 5 deletions(-) diff --git a/include/gopher/orch/agent/agent_types.h b/include/gopher/orch/agent/agent_types.h index 1a6e1cf8..fdd487df 100644 --- a/include/gopher/orch/agent/agent_types.h +++ b/include/gopher/orch/agent/agent_types.h @@ -148,26 +148,33 @@ struct AgentStep { }; // 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 + // Conversation history (uses APPEND reducer) std::vector messages; - // Steps taken + // Steps taken (uses APPEND reducer) std::vector steps; - // Current iteration + // Current iteration (last-write-wins) int current_iteration = 0; - // Token usage + // 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) + // Error info (if failed, last-write-wins) optional error; // Check if agent is still running @@ -182,6 +189,215 @@ struct AgentState { 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; + } }; // ═══════════════════════════════════════════════════════════════════════════ From 6657927650d8eb29145fdfd74277e2e4c5150591 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:24:50 -0800 Subject: [PATCH 163/197] Add AgentState unit tests (#27) Tests for reducer semantics (APPEND, ACCUMULATE, LAST-WRITE-WINS) and JSON serialization round-trip with messages and tool calls. --- tests/gopher/orch/agent_state_test.cc | 392 ++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 tests/gopher/orch/agent_state_test.cc diff --git a/tests/gopher/orch/agent_state_test.cc b/tests/gopher/orch/agent_state_test.cc new file mode 100644 index 00000000..b84c4fde --- /dev/null +++ b/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"); +} From 0431e2864ca97e91938d38ff5032e71ee39cfd8f Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:25:06 -0800 Subject: [PATCH 164/197] Add AgentRunnable header (#27) Wraps ReAct agent as Runnable interface. Supports step callbacks, tool approval callbacks, and config overrides. --- include/gopher/orch/agent/agent_runnable.h | 213 +++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 include/gopher/orch/agent/agent_runnable.h diff --git a/include/gopher/orch/agent/agent_runnable.h b/include/gopher/orch/agent/agent_runnable.h new file mode 100644 index 00000000..70b02ec2 --- /dev/null +++ b/include/gopher/orch/agent/agent_runnable.h @@ -0,0 +1,213 @@ +#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 From 7a9a251ab3ea8e5320c607bc3ed0d972c4fe7cad Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:27:03 -0800 Subject: [PATCH 165/197] Add AgentRunnable implementation (#27) Implements ReAct loop: executeLoop -> callLLM -> handleLLMResponse -> executeTools. Handles max iterations, timeouts, tool approval, and step callbacks. --- src/gopher/orch/agent/agent_runnable.cc | 496 ++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 src/gopher/orch/agent/agent_runnable.cc diff --git a/src/gopher/orch/agent/agent_runnable.cc b/src/gopher/orch/agent/agent_runnable.cc new file mode 100644 index 00000000..adafec55 --- /dev/null +++ b/src/gopher/orch/agent/agent_runnable.cc @@ -0,0 +1,496 @@ +// 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 From 2d55a2cba8593511ad8f8b08d43ec150f4a50f87 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:29:44 -0800 Subject: [PATCH 166/197] Add AgentRunnable unit tests (#27) Tests for simple queries, tool calls, config overrides, max iterations, context handling, step callbacks, tool approval, error cases, and output structure. --- tests/gopher/orch/agent_runnable_test.cc | 490 +++++++++++++++++++++++ 1 file changed, 490 insertions(+) create mode 100644 tests/gopher/orch/agent_runnable_test.cc diff --git a/tests/gopher/orch/agent_runnable_test.cc b/tests/gopher/orch/agent_runnable_test.cc new file mode 100644 index 00000000..84fc6bca --- /dev/null +++ b/tests/gopher/orch/agent_runnable_test.cc @@ -0,0 +1,490 @@ +// 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); +} From 7342003d0388f6c18f525e875a4b40eedb52eb25 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:29:58 -0800 Subject: [PATCH 167/197] Add LLMRunnable and agent sources to build (#27) Add llm_runnable.cc, tool_runnable.cc, agent_runnable.cc to ORCH_LLM_SOURCES and ORCH_AGENT_SOURCES. --- src/CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a9087d8a..4ba56bdb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,7 @@ if(NOT BUILD_WITHOUT_GOPHER_MCP) 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() @@ -32,6 +33,8 @@ if(NOT BUILD_WITHOUT_GOPHER_MCP) gopher/orch/agent/agent.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 ) endif() From a08b6d6e0267a77c105482ab5fa585e290413d8a Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:30:20 -0800 Subject: [PATCH 168/197] Add new runnable tests to build (#27) Add llm_runnable_test.cc, agent_state_test.cc, agent_runnable_test.cc, tool_runnable_test.cc to ORCH_AGENT_TEST_SOURCES. --- tests/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 78c88eb4..f3567537 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,8 +34,12 @@ set(ORCH_FRAMEWORK_TEST_SOURCES # 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 ) # FFI tests - organized by component From 085f56ede53dde73ff0f3e5288982cbadea9d5c7 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Fri, 2 Jan 2026 00:31:41 -0800 Subject: [PATCH 169/197] make format code to apply clang-format (#27) --- include/gopher/orch/agent/agent_runnable.h | 17 ++++---- include/gopher/orch/agent/agent_types.h | 48 ++++++++++++++-------- src/gopher/orch/agent/agent_runnable.cc | 45 ++++++++++---------- tests/gopher/orch/agent_runnable_test.cc | 43 ++++++++----------- tests/gopher/orch/agent_state_test.cc | 4 +- tests/gopher/orch/llm_runnable_test.cc | 21 ++++------ tests/gopher/orch/tool_runnable_test.cc | 3 +- 7 files changed, 96 insertions(+), 85 deletions(-) diff --git a/include/gopher/orch/agent/agent_runnable.h b/include/gopher/orch/agent/agent_runnable.h index 70b02ec2..fffa313b 100644 --- a/include/gopher/orch/agent/agent_runnable.h +++ b/include/gopher/orch/agent/agent_runnable.h @@ -3,7 +3,8 @@ // 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. +// 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). @@ -197,14 +198,16 @@ class AgentRunnable : public Runnable { }; // 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, + 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()) { +inline AgentRunnablePtr makeAgentRunnable( + LLMProviderPtr provider, const AgentConfig& config = AgentConfig()) { return AgentRunnable::create(std::move(provider), config); } diff --git a/include/gopher/orch/agent/agent_types.h b/include/gopher/orch/agent/agent_types.h index fdd487df..9a63553e 100644 --- a/include/gopher/orch/agent/agent_types.h +++ b/include/gopher/orch/agent/agent_types.h @@ -201,7 +201,8 @@ struct AgentState { // - 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) { + static AgentState reduce(const AgentState& current, + const AgentState& update) { AgentState result; // APPEND: messages @@ -228,7 +229,8 @@ struct AgentState { 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; + 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; @@ -300,20 +302,27 @@ struct AgentState { // 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; + 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()) { + 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()) { + if (json.contains("remaining_steps") && + json["remaining_steps"].isNumber()) { state.remaining_steps = json["remaining_steps"].getInt(); } @@ -322,7 +331,8 @@ struct AgentState { 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; + if (!msg_json.isObject()) + continue; Role role = Role::USER; if (msg_json.contains("role") && msg_json["role"].isString()) { @@ -336,16 +346,19 @@ struct AgentState { Message msg(role, content); - if (msg_json.contains("tool_call_id") && msg_json["tool_call_id"].isString()) { + 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()) { + 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; + if (!call_json.isObject()) + continue; ToolCall call; if (call_json.contains("id") && call_json["id"].isString()) { call.id = call_json["id"].getString(); @@ -370,14 +383,17 @@ struct AgentState { // 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()) { + 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(); + state.total_usage.completion_tokens = + usage_json["completion_tokens"].getInt(); } - if (usage_json.contains("total_tokens") && usage_json["total_tokens"].isNumber()) { + if (usage_json.contains("total_tokens") && + usage_json["total_tokens"].isNumber()) { state.total_usage.total_tokens = usage_json["total_tokens"].getInt(); } } diff --git a/src/gopher/orch/agent/agent_runnable.cc b/src/gopher/orch/agent/agent_runnable.cc index adafec55..c022d0dc 100644 --- a/src/gopher/orch/agent/agent_runnable.cc +++ b/src/gopher/orch/agent/agent_runnable.cc @@ -15,7 +15,8 @@ namespace agent { AgentRunnable::Ptr AgentRunnable::create(LLMProviderPtr provider, ToolExecutorPtr executor, const AgentConfig& config) { - return Ptr(new AgentRunnable(std::move(provider), std::move(executor), config)); + return Ptr( + new AgentRunnable(std::move(provider), std::move(executor), config)); } AgentRunnable::Ptr AgentRunnable::create(LLMProviderPtr provider, @@ -41,9 +42,7 @@ AgentRunnable::AgentRunnable(LLMProviderPtr provider, // Runnable Interface // ============================================================================= -std::string AgentRunnable::name() const { - return "AgentRunnable"; -} +std::string AgentRunnable::name() const { return "AgentRunnable"; } void AgentRunnable::invoke(const JsonValue& input, const RunnableConfig& /* runnable_config */, @@ -51,8 +50,8 @@ void AgentRunnable::invoke(const JsonValue& input, Callback callback) { // Validate provider if (!provider_) { - postError(dispatcher, std::move(callback), AgentError::NO_PROVIDER, - "No LLM provider configured"); + postError(dispatcher, std::move(callback), + AgentError::NO_PROVIDER, "No LLM provider configured"); return; } @@ -93,7 +92,8 @@ void AgentRunnable::invoke(const JsonValue& input, // Input Parsing // ============================================================================= -AgentRunnable::ParsedInput AgentRunnable::parseInput(const JsonValue& input) const { +AgentRunnable::ParsedInput AgentRunnable::parseInput( + const JsonValue& input) const { ParsedInput result; result.config = config_; // Start with current config @@ -117,7 +117,8 @@ AgentRunnable::ParsedInput AgentRunnable::parseInput(const JsonValue& input) con 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; + if (!msg_json.isObject()) + continue; Role role = Role::USER; if (msg_json.contains("role") && msg_json["role"].isString()) { @@ -138,7 +139,8 @@ AgentRunnable::ParsedInput AgentRunnable::parseInput(const JsonValue& input) con 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; + if (!msg_json.isObject()) + continue; Role role = Role::USER; if (msg_json.contains("role") && msg_json["role"].isString()) { @@ -261,8 +263,8 @@ void AgentRunnable::executeTools(const std::vector& calls, for (const auto& call : calls) { if (!approval_callback_(call)) { state.status = AgentStatus::CANCELLED; - state.error = Error(AgentError::CANCELLED, - "Tool call rejected: " + call.name); + state.error = + Error(AgentError::CANCELLED, "Tool call rejected: " + call.name); completeRun(state, std::move(callback)); return; } @@ -277,18 +279,17 @@ void AgentRunnable::executeTools(const std::vector& calls, 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)); - }); + 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)]( + [this, calls, state, &dispatcher, callback = std::move(callback)]( std::vector> results) mutable { // Update last step with tool executions if (!state.steps.empty()) { @@ -343,7 +344,8 @@ void AgentRunnable::completeRun(AgentState& state, Callback callback) { // 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"); + state.error = + Error(AgentError::MAX_ITERATIONS, "Maximum iterations reached"); } // Build output @@ -353,8 +355,8 @@ void AgentRunnable::completeRun(AgentState& state, Callback callback) { callback(Result(std::move(output))); } else { // Return error - callback(Result(state.error.value_or( - Error(AgentError::UNKNOWN, "Unknown error")))); + callback(Result( + state.error.value_or(Error(AgentError::UNKNOWN, "Unknown error")))); } } @@ -430,7 +432,8 @@ JsonValue AgentRunnable::buildOutput(const AgentState& state) const { // Helpers // ============================================================================= -std::vector AgentRunnable::buildMessages(const AgentState& state) const { +std::vector AgentRunnable::buildMessages( + const AgentState& state) const { std::vector messages; // Add system prompt if configured diff --git a/tests/gopher/orch/agent_runnable_test.cc b/tests/gopher/orch/agent_runnable_test.cc index 84fc6bca..8819e32c 100644 --- a/tests/gopher/orch/agent_runnable_test.cc +++ b/tests/gopher/orch/agent_runnable_test.cc @@ -36,8 +36,7 @@ class AgentRunnableTest : public OrchTest { void addTestTools() { // Search tool registry_->addTool( - "search", "Search the web", - JsonValue::object(), + "search", "Search the web", JsonValue::object(), [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { std::string query = "default"; if (args.contains("query") && args["query"].isString()) { @@ -55,11 +54,9 @@ class AgentRunnableTest : public OrchTest { // Calculator tool registry_->addSyncTool( - "calculator", "Perform calculations", - JsonValue::object(), + "calculator", "Perform calculations", JsonValue::object(), [](const JsonValue& args) -> Result { - if (args.contains("expression") && - args["expression"].isString()) { + if (args.contains("expression") && args["expression"].isString()) { std::string expr = args["expression"].getString(); if (expr == "2+2") { return Result(JsonValue(4)); @@ -74,9 +71,7 @@ class AgentRunnableTest : public OrchTest { // Basic Tests // ============================================================================= -TEST_F(AgentRunnableTest, Name) { - EXPECT_EQ(agent_->name(), "AgentRunnable"); -} +TEST_F(AgentRunnableTest, Name) { EXPECT_EQ(agent_->name(), "AgentRunnable"); } TEST_F(AgentRunnableTest, Accessors) { EXPECT_EQ(agent_->provider(), mock_provider_); @@ -138,7 +133,8 @@ TEST_F(AgentRunnableTest, SingleToolCall) { mock_provider_->queueToolCalls(tool_calls); // Second response: final answer - mock_provider_->queueResponse("Based on the search, the weather in Tokyo is sunny."); + mock_provider_->queueResponse( + "Based on the search, the weather in Tokyo is sunny."); JsonValue input = "What is the weather in Tokyo?"; @@ -206,10 +202,9 @@ TEST_F(AgentRunnableTest, ConfigOverridesInInput) { config["model"] = "gpt-3.5-turbo"; input["config"] = config; - runToCompletion( - [&](Dispatcher& d, ResultCallback cb) { - agent_->invoke(input, RunnableConfig(), d, std::move(cb)); - }); + 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"); @@ -228,8 +223,7 @@ TEST_F(AgentRunnableTest, MaxIterations) { // Create agent with low max iterations auto limited_agent = AgentRunnable::create( - mock_provider_, executor_, - AgentConfig("gpt-4").withMaxIterations(3)); + mock_provider_, executor_, AgentConfig("gpt-4").withMaxIterations(3)); JsonValue input = "Test query"; @@ -265,10 +259,9 @@ TEST_F(AgentRunnableTest, WithContext) { input["context"] = context; - runToCompletion( - [&](Dispatcher& d, ResultCallback cb) { - agent_->invoke(input, RunnableConfig(), d, std::move(cb)); - }); + runToCompletion([&](Dispatcher& d, ResultCallback cb) { + agent_->invoke(input, RunnableConfig(), d, std::move(cb)); + }); // Verify context was included auto last_msgs = mock_provider_->lastMessages(); @@ -331,10 +324,9 @@ TEST_F(AgentRunnableTest, StepCallback) { JsonValue input = "Test"; - runToCompletion( - [&](Dispatcher& d, ResultCallback cb) { - agent_->invoke(input, RunnableConfig(), d, std::move(cb)); - }); + 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); @@ -476,7 +468,8 @@ TEST_F(AgentRunnableTest, OutputContainsDuration) { // ============================================================================= TEST_F(AgentRunnableTest, MakeAgentRunnableWithRegistry) { - auto agent = makeAgentRunnable(mock_provider_, registry_, AgentConfig("gpt-4")); + auto agent = + makeAgentRunnable(mock_provider_, registry_, AgentConfig("gpt-4")); EXPECT_NE(agent, nullptr); EXPECT_EQ(agent->provider(), mock_provider_); EXPECT_EQ(agent->registry(), registry_); diff --git a/tests/gopher/orch/agent_state_test.cc b/tests/gopher/orch/agent_state_test.cc index b84c4fde..289354d5 100644 --- a/tests/gopher/orch/agent_state_test.cc +++ b/tests/gopher/orch/agent_state_test.cc @@ -1,7 +1,6 @@ // Unit tests for AgentState reducer and JSON serialization #include "gopher/orch/agent/agent_types.h" - #include "gtest/gtest.h" using namespace gopher::orch::agent; @@ -318,7 +317,8 @@ TEST(AgentStateJsonTest, RoundTrip) { 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.total_usage.prompt_tokens, + original.total_usage.prompt_tokens); EXPECT_EQ(restored.messages.size(), original.messages.size()); // Check messages diff --git a/tests/gopher/orch/llm_runnable_test.cc b/tests/gopher/orch/llm_runnable_test.cc index 9db935e2..d1cae780 100644 --- a/tests/gopher/orch/llm_runnable_test.cc +++ b/tests/gopher/orch/llm_runnable_test.cc @@ -164,10 +164,9 @@ TEST_F(LLMRunnableTest, ConfigOverrides) { config["max_tokens"] = 100; input["config"] = config; - runToCompletion( - [&](Dispatcher& d, ResultCallback cb) { - llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); - }); + 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"); @@ -186,10 +185,9 @@ TEST_F(LLMRunnableTest, DefaultConfigUsed) { JsonValue input = "Hello"; - runToCompletion( - [&](Dispatcher& d, ResultCallback cb) { - llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); - }); + 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"); @@ -297,10 +295,9 @@ TEST_F(LLMRunnableTest, ToolResultMessageParsing) { input["messages"] = messages; - runToCompletion( - [&](Dispatcher& d, ResultCallback cb) { - llm_runnable_->invoke(input, RunnableConfig(), d, std::move(cb)); - }); + 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); diff --git a/tests/gopher/orch/tool_runnable_test.cc b/tests/gopher/orch/tool_runnable_test.cc index a86668c0..3df740cc 100644 --- a/tests/gopher/orch/tool_runnable_test.cc +++ b/tests/gopher/orch/tool_runnable_test.cc @@ -33,8 +33,7 @@ class ToolRunnableTest : public OrchTest { registry_->addSyncTool( "calculator", "Perform calculations", makeSchema(), [](const JsonValue& args) -> Result { - if (args.contains("expression") && - args["expression"].isString()) { + if (args.contains("expression") && args["expression"].isString()) { std::string expr = args["expression"].getString(); if (expr == "2+2") { return Result(JsonValue(4)); From 30cffad9fb2608d19766a0a3d4349272892f77fb Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 13:17:47 -0800 Subject: [PATCH 170/197] Add comprehensive README with SEO/AEO optimization (#29) Include architecture overview, code examples, feature comparison, installation guide, use cases, and cross-language FFI documentation. Optimized for search engines and AI answer engines. --- README.md | 378 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..fb47a324 --- /dev/null +++ b/README.md @@ -0,0 +1,378 @@ +# Gopher Orch - AI Agent Orchestration Framework for C++ + +[![C++14](https://img.shields.io/badge/C%2B%2B-14%2F17%2F20-blue.svg)](https://isocpp.org/) +[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-green.svg)](https://modelcontextprotocol.io/) +[![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)]() + +**Gopher Orch / C++ AI Agent Framework** - A production-ready, protocol-agnostic orchestration framework for building AI agents and agentic workflows in modern C++. LangChain-style composability with explicit, non-magical design. + +## What is Gopher Orch? + +Gopher Orch is a **C++ AI agent orchestration framework** that provides composable building blocks for creating intelligent AI agents. 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. + +### Key Benefits + +- **LangChain-Style Composability**: Chain operations with `|` operator, build complex workflows from simple components +- **Protocol-Agnostic**: Works with MCP, REST, gRPC, or custom protocols interchangeably +- **Testable-by-Design**: MockServer support for unit testing without network dependencies +- **Production-Ready**: Circuit breaker, retry, timeout, and fallback patterns built-in +- **Cross-Language**: C API (FFI) for Python, Rust, Go, Node.js, Java, and more + +## Why Choose Gopher Orch? + +| Feature | Gopher Orch | LangChain | LlamaIndex | +|---------|-------------|-----------|------------| +| Language | C++ (with FFI bindings) | Python | Python | +| Performance | Native speed, zero-copy | Interpreted | Interpreted | +| Type Safety | Compile-time checked | Runtime | Runtime | +| Composability | Explicit `Runnable` | Magic methods | Index abstractions | +| Protocol Support | MCP, REST, Mock | Various | Various | +| 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 + +`C++ AI Agent`, `C++ LLM Framework`, `AI Agent Orchestration C++`, `ReAct Agent C++`, `LangChain C++`, `LangGraph C++`, `C++ AI Framework`, `MCP Agent`, `Model Context Protocol Agent`, `C++ Chatbot Framework`, `AI Workflow C++`, `Tool Calling Agent C++`, `Agentic AI C++`, `C++ LLM Integration`, `Production AI Agent`, `Enterprise AI Framework C++` From 8fab5f7ca929ceaa37cdeedfdbb9b157b6c0ef30 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 14:47:08 -0800 Subject: [PATCH 171/197] Add Runnable interface documentation (#29) Documents the core Runnable interface, design principles, usage examples, and best practices for composable operations. --- docs/Runnable.md | 222 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/Runnable.md diff --git a/docs/Runnable.md b/docs/Runnable.md new file mode 100644 index 00000000..f5e12f4a --- /dev/null +++ b/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 From 56dc74a0b4f6262d360f040b35462bc94e5ef2d7 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 14:48:01 -0800 Subject: [PATCH 172/197] Add Composition patterns documentation (#29) Documents Sequence, Parallel, and Router patterns with usage examples, nesting, and integration with resilience wrappers. --- docs/Composition.md | 258 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/Composition.md diff --git a/docs/Composition.md b/docs/Composition.md new file mode 100644 index 00000000..7a779aa8 --- /dev/null +++ b/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 From 0a8bb6f15c7ad180ea0d4119f218a646b5e4e8db Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 14:54:35 -0800 Subject: [PATCH 173/197] Add StateGraph documentation (#29) Documents LangGraph-style stateful workflows with Pregel execution model, conditional edges, state reducers, and ReAct agent example. --- docs/StateGraph.md | 305 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/StateGraph.md diff --git a/docs/StateGraph.md b/docs/StateGraph.md new file mode 100644 index 00000000..f4c1d14e --- /dev/null +++ b/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 From d6ebcf85513dc2317d87a40478396dfd453a6538 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 14:54:59 -0800 Subject: [PATCH 174/197] Add Resilience patterns documentation (#29) Documents Retry, Timeout, Fallback, and CircuitBreaker patterns with configuration options, combining patterns, and observability. --- docs/Resilience.md | 323 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/Resilience.md diff --git a/docs/Resilience.md b/docs/Resilience.md new file mode 100644 index 00000000..385076de --- /dev/null +++ b/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 From 67f31d1fe317fb7e08507d2517bba589962c5e93 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 14:55:11 -0800 Subject: [PATCH 175/197] Add Server abstraction documentation (#29) Documents protocol-agnostic server interface, MCP/REST/Mock servers, tool registry, composite servers, and tool approval. --- docs/Server.md | 301 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/Server.md diff --git a/docs/Server.md b/docs/Server.md new file mode 100644 index 00000000..84693e24 --- /dev/null +++ b/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 From 71d42711d35d1541172a00de0b1b90f5d27c6f35 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 14:55:25 -0800 Subject: [PATCH 176/197] Add FFI guide documentation (#29) Documents C API design, Python/Rust/Go/Node.js bindings, memory management, thread safety, and custom binding creation. --- docs/FFI.md | 414 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 docs/FFI.md diff --git a/docs/FFI.md b/docs/FFI.md new file mode 100644 index 00000000..e2515801 --- /dev/null +++ b/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 From 0a6560096aa342aa62a00301a8d9e2aab59cf55f Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 15:16:57 -0800 Subject: [PATCH 177/197] Revise README to emphasize cross-language and MCP-native (#29) - Update title to "Cross-Language MCP Orchestration Framework" - Add tagline "LangChain + Vercel AI SDK for Model Context Protocol" - Reorder benefits: MCP-Native and Cross-Language first - Update comparison table with MCP Support and Streaming rows - Update keywords for MCP-focused SEO --- README.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fb47a324..91c0b0ca 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,37 @@ -# Gopher Orch - AI Agent Orchestration Framework for C++ +# Gopher Orch - Cross-Language MCP Orchestration Framework -[![C++14](https://img.shields.io/badge/C%2B%2B-14%2F17%2F20-blue.svg)](https://isocpp.org/) -[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-green.svg)](https://modelcontextprotocol.io/) +[![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)]() -**Gopher Orch / C++ AI Agent Framework** - A production-ready, protocol-agnostic orchestration framework for building AI agents and agentic workflows in modern C++. LangChain-style composability with explicit, non-magical design. +**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 **C++ AI agent orchestration framework** that provides composable building blocks for creating intelligent AI agents. 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. +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 -- **Protocol-Agnostic**: Works with MCP, REST, gRPC, or custom protocols interchangeably -- **Testable-by-Design**: MockServer support for unit testing without network dependencies +- **Vercel AI SDK Patterns**: Streaming, structured outputs, and modern async patterns - **Production-Ready**: Circuit breaker, retry, timeout, and fallback patterns built-in -- **Cross-Language**: C API (FFI) for Python, Rust, Go, Node.js, Java, and more +- **Testable-by-Design**: MockServer support for unit testing without network dependencies ## Why Choose Gopher Orch? | Feature | Gopher Orch | LangChain | LlamaIndex | |---------|-------------|-----------|------------| -| Language | C++ (with FFI bindings) | Python | Python | +| 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 | -| Protocol Support | MCP, REST, Mock | Various | Various | +| Streaming | Built-in | Callback-based | Callback-based | | Memory Control | RAII, deterministic | GC-managed | GC-managed | ## Architecture Overview @@ -375,4 +379,4 @@ Apache License 2.0 - see [LICENSE](LICENSE) for details. ## Keywords & Search Terms -`C++ AI Agent`, `C++ LLM Framework`, `AI Agent Orchestration C++`, `ReAct Agent C++`, `LangChain C++`, `LangGraph C++`, `C++ AI Framework`, `MCP Agent`, `Model Context Protocol Agent`, `C++ Chatbot Framework`, `AI Workflow C++`, `Tool Calling Agent C++`, `Agentic AI C++`, `C++ LLM Integration`, `Production AI Agent`, `Enterprise AI Framework C++` +`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` From 389eae63bb65a707c69b39dfb909d71a4bb5a7ba Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 16:47:24 -0800 Subject: [PATCH 178/197] Add simple_agent example - Basic ReAct agent with tools (#31) - Demonstrates LLM provider, tool registry, and AgentRunnable - Includes calculator, weather, and search tools - Shows step callbacks for observability --- examples/simple_agent/README.md | 73 ++++++++++++++ examples/simple_agent/main.cc | 167 ++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 examples/simple_agent/README.md create mode 100644 examples/simple_agent/main.cc diff --git a/examples/simple_agent/README.md b/examples/simple_agent/README.md new file mode 100644 index 00000000..eef8cd0c --- /dev/null +++ b/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/examples/simple_agent/main.cc b/examples/simple_agent/main.cc new file mode 100644 index 00000000..5f7b4945 --- /dev/null +++ b/examples/simple_agent/main.cc @@ -0,0 +1,167 @@ +// 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 "gopher/orch/orch.h" + +#include + +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; +} From 90c5159bf7563f6c884584685d270902e336ca50 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 16:47:42 -0800 Subject: [PATCH 179/197] Add chatbot example - Multi-turn conversational agent (#31) - Maintains conversation history across turns - Interactive REPL-style interface - Demonstrates tool usage within conversation flow --- examples/chatbot/README.md | 109 +++++++++++++++++++++++++ examples/chatbot/main.cc | 161 +++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 examples/chatbot/README.md create mode 100644 examples/chatbot/main.cc diff --git a/examples/chatbot/README.md b/examples/chatbot/README.md new file mode 100644 index 00000000..2bb97c05 --- /dev/null +++ b/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/examples/chatbot/main.cc b/examples/chatbot/main.cc new file mode 100644 index 00000000..f03f4dd7 --- /dev/null +++ b/examples/chatbot/main.cc @@ -0,0 +1,161 @@ +// Multi-turn Conversational Agent Example +// +// Demonstrates a chatbot that maintains conversation history +// and can use tools across multiple turns. + +#include "gopher/orch/orch.h" + +#include +#include + +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; +} From d15794a5fda960fa54e5b063b532ab3e62fe0bb8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 16:48:07 -0800 Subject: [PATCH 180/197] Add workflow example - StateGraph-based document processing (#31) - Demonstrates StateGraph with conditional branching - Shows state merging with reducer functions - Document classification and summarization workflow --- examples/workflow/README.md | 151 ++++++++++++++++++++++++ examples/workflow/main.cc | 223 ++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 examples/workflow/README.md create mode 100644 examples/workflow/main.cc diff --git a/examples/workflow/README.md b/examples/workflow/README.md new file mode 100644 index 00000000..b59c7e17 --- /dev/null +++ b/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/examples/workflow/main.cc b/examples/workflow/main.cc new file mode 100644 index 00000000..2537d00b --- /dev/null +++ b/examples/workflow/main.cc @@ -0,0 +1,223 @@ +// StateGraph Workflow Example +// +// Demonstrates a document processing workflow using StateGraph. +// Shows conditional branching, node execution, and state management. + +#include "gopher/orch/orch.h" + +#include +#include + +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; +} From 30d6d474b270a74223b81c388bed50a0c52ff7b0 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 16:48:19 -0800 Subject: [PATCH 181/197] Add resilient_api example - API client with resilience patterns (#31) - Retry with exponential backoff - Timeout protection - Fallback on failure - Circuit breaker for failure isolation --- examples/resilient_api/README.md | 127 ++++++++++++++ examples/resilient_api/main.cc | 290 +++++++++++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 examples/resilient_api/README.md create mode 100644 examples/resilient_api/main.cc diff --git a/examples/resilient_api/README.md b/examples/resilient_api/README.md new file mode 100644 index 00000000..cc7b4fc0 --- /dev/null +++ b/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/examples/resilient_api/main.cc b/examples/resilient_api/main.cc new file mode 100644 index 00000000..00637838 --- /dev/null +++ b/examples/resilient_api/main.cc @@ -0,0 +1,290 @@ +// 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 "gopher/orch/orch.h" + +#include +#include +#include + +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; +} From ef48da5c881ea51f1b80ffdeffa7143b02305926 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 16:48:36 -0800 Subject: [PATCH 182/197] Add multi_agent example - Multi-agent coordination (#31) - Specialized agents: researcher, analyzer, writer - Sequential agent coordination pipeline - Demonstrates passing data between agents --- examples/multi_agent/README.md | 159 +++++++++++++++++++ examples/multi_agent/main.cc | 275 +++++++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 examples/multi_agent/README.md create mode 100644 examples/multi_agent/main.cc diff --git a/examples/multi_agent/README.md b/examples/multi_agent/README.md new file mode 100644 index 00000000..7c6d8193 --- /dev/null +++ b/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/examples/multi_agent/main.cc b/examples/multi_agent/main.cc new file mode 100644 index 00000000..2af216f6 --- /dev/null +++ b/examples/multi_agent/main.cc @@ -0,0 +1,275 @@ +// 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 "gopher/orch/orch.h" + +#include +#include + +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; +} From ae1f756c02972b6a35219c3406c71f217ae5f91b Mon Sep 17 00:00:00 2001 From: gophergogo Date: Sun, 4 Jan 2026 16:49:54 -0800 Subject: [PATCH 183/197] make format code to apply clang-format (#31) --- examples/chatbot/main.cc | 41 +++++------ examples/multi_agent/main.cc | 120 ++++++++++++++------------------- examples/resilient_api/main.cc | 79 +++++++++------------- examples/simple_agent/main.cc | 28 ++++---- examples/workflow/main.cc | 91 +++++++++++++------------ 5 files changed, 163 insertions(+), 196 deletions(-) diff --git a/examples/chatbot/main.cc b/examples/chatbot/main.cc index f03f4dd7..21e32258 100644 --- a/examples/chatbot/main.cc +++ b/examples/chatbot/main.cc @@ -3,11 +3,11 @@ // Demonstrates a chatbot that maintains conversation history // and can use tools across multiple turns. -#include "gopher/orch/orch.h" - #include #include +#include "gopher/orch/orch.h" + using namespace gopher::orch; using namespace gopher::orch::agent; using namespace gopher::orch::llm; @@ -18,10 +18,10 @@ class Chatbot { 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.")); + 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 @@ -34,10 +34,7 @@ class Chatbot { // Create agent for this turn auto executor = makeToolExecutor(registry_); auto agent = AgentRunnable::create( - provider_, - executor, - AgentConfig("gpt-4") - .withMaxIterations(5)); + provider_, executor, AgentConfig("gpt-4").withMaxIterations(5)); // Build input with conversation context JsonValue input = JsonValue::object(); @@ -52,9 +49,7 @@ class Chatbot { input["query"] = ""; // Query is already in context agent->invoke( - input, - RunnableConfig(), - dispatcher, + input, RunnableConfig(), dispatcher, [this, on_response = std::move(on_response)](Result result) { if (mcp::holds_alternative(result)) { on_response("Error: " + mcp::get(result).message); @@ -77,8 +72,8 @@ class Chatbot { // Clear conversation (start fresh) void reset() { conversation_.clear(); - conversation_.push_back(Message::system( - "You are a helpful conversational assistant.")); + conversation_.push_back( + Message::system("You are a helpful conversational assistant.")); } private: @@ -102,18 +97,15 @@ int main() { // Add some tools registry->addSyncTool( - "remember", - "Remember a fact for later. Input: {\"fact\": \"...\"}", - JsonValue::object(), - [](const JsonValue& args) -> Result { + "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())); + return makeSuccess( + JsonValue("Remembered: " + args["fact"].getString())); }); registry->addSyncTool( - "get_time", - "Get current time", - JsonValue::object(), + "get_time", "Get current time", JsonValue::object(), [](const JsonValue&) -> Result { return makeSuccess(JsonValue("Current time: 2:30 PM")); }); @@ -121,7 +113,8 @@ int main() { // Create chatbot Chatbot chatbot(provider, registry); - std::cout << "Chatbot ready! Type 'quit' to exit, 'reset' to clear history.\n"; + std::cout + << "Chatbot ready! Type 'quit' to exit, 'reset' to clear history.\n"; std::cout << "========================================\n\n"; // Interactive loop diff --git a/examples/multi_agent/main.cc b/examples/multi_agent/main.cc index 2af216f6..1c3f1182 100644 --- a/examples/multi_agent/main.cc +++ b/examples/multi_agent/main.cc @@ -6,11 +6,11 @@ // - Writer agent: Generates reports // - Coordinator: Orchestrates the workflow -#include "gopher/orch/orch.h" - #include #include +#include "gopher/orch/orch.h" + using namespace gopher::orch; using namespace gopher::orch::agent; using namespace gopher::orch::llm; @@ -24,17 +24,14 @@ struct AgentResult { }; // 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)); +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() { @@ -59,8 +56,7 @@ int main() { researchTools->addSyncTool( "search_web", "Search the web for information. Input: {\"query\": \"...\"}", - JsonValue::object(), - [](const JsonValue& args) -> Result { + JsonValue::object(), [](const JsonValue& args) -> Result { auto query = args["query"].getString(); JsonValue results = JsonValue::object(); results["query"] = query; @@ -73,10 +69,8 @@ int main() { }); researchTools->addSyncTool( - "fetch_data", - "Fetch data from a source. Input: {\"source\": \"...\"}", - JsonValue::object(), - [](const JsonValue& args) -> Result { + "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; @@ -90,8 +84,7 @@ int main() { }); auto researcher = createSpecializedAgent( - provider, - "Researcher", + provider, "Researcher", "You are a research specialist. Your job is to gather information " "using search and data fetching tools. Be thorough and systematic.", researchTools); @@ -101,8 +94,7 @@ int main() { analyzerTools->addSyncTool( "calculate_stats", "Calculate statistics on data. Input: {\"values\": [...]}", - JsonValue::object(), - [](const JsonValue& args) -> Result { + JsonValue::object(), [](const JsonValue& args) -> Result { auto& values = args["values"]; double sum = 0; double min = 1e9, max = -1e9; @@ -111,8 +103,10 @@ int main() { 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; + if (val < min) + min = val; + if (val > max) + max = val; count++; } @@ -126,10 +120,8 @@ int main() { }); analyzerTools->addSyncTool( - "identify_trends", - "Identify trends in data. Input: {\"data\": [...]}", - JsonValue::object(), - [](const JsonValue& args) -> Result { + "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; @@ -138,8 +130,7 @@ int main() { }); auto analyzer = createSpecializedAgent( - provider, - "Analyzer", + provider, "Analyzer", "You are a data analyst. Your job is to analyze data, calculate " "statistics, and identify trends. Provide clear insights.", analyzerTools); @@ -148,9 +139,9 @@ int main() { auto writerTools = makeToolRegistry(); writerTools->addSyncTool( "format_report", - "Format content as a report. Input: {\"title\": \"...\", \"sections\": [...]}", - JsonValue::object(), - [](const JsonValue& args) -> Result { + "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++) { @@ -163,8 +154,7 @@ int main() { }); auto writer = createSpecializedAgent( - provider, - "Writer", + provider, "Writer", "You are a technical writer. Your job is to create clear, " "well-structured reports from research and analysis results.", writerTools); @@ -186,20 +176,17 @@ int main() { 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; - }); + 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); @@ -215,20 +202,17 @@ int main() { 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; - }); + 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); @@ -245,13 +229,11 @@ int main() { input["query"] = "Create a report on: " + topic; writer->invoke( - input, - RunnableConfig(), - *dispatcher, + input, RunnableConfig(), *dispatcher, [&done](Result result) { if (mcp::holds_alternative(result)) { - std::cerr << "Writing failed: " - << mcp::get(result).message << "\n"; + std::cerr << "Writing failed: " << mcp::get(result).message + << "\n"; } else { auto& output = mcp::get(result); std::cout << " Report generated.\n\n"; diff --git a/examples/resilient_api/main.cc b/examples/resilient_api/main.cc index 00637838..9a9c1630 100644 --- a/examples/resilient_api/main.cc +++ b/examples/resilient_api/main.cc @@ -6,12 +6,12 @@ // - Fallback on failure // - Circuit breaker for failure isolation -#include "gopher/orch/orch.h" - #include #include #include +#include "gopher/orch/orch.h" + using namespace gopher::orch; using namespace gopher::orch::core; using namespace gopher::orch::resilience; @@ -46,8 +46,7 @@ class UnreliableApiClient { [this, endpoint, will_fail, latency, callback = std::move(callback)]() { if (will_fail) { callback(makeOrchError( - OrchError::NETWORK_ERROR, - "Connection failed to " + endpoint)); + OrchError::NETWORK_ERROR, "Connection failed to " + endpoint)); } else { ApiResponse response; response.success = true; @@ -71,8 +70,7 @@ class UnreliableApiClient { RunnablePtr makeApiRunnable( std::shared_ptr client) { return makeLambda( - [client](const std::string& endpoint, - Dispatcher& dispatcher, + [client](const std::string& endpoint, Dispatcher& dispatcher, ResultCallback callback) { client->fetch(endpoint, dispatcher, std::move(callback)); }); @@ -95,10 +93,10 @@ int main() { std::cout << "----------------------------------------\n"; auto retryConfig = RetryConfig() - .withMaxAttempts(3) - .withInitialDelay(std::chrono::milliseconds(100)) - .withMaxDelay(std::chrono::milliseconds(1000)) - .withBackoffMultiplier(2.0); + .withMaxAttempts(3) + .withInitialDelay(std::chrono::milliseconds(100)) + .withMaxDelay(std::chrono::milliseconds(1000)) + .withBackoffMultiplier(2.0); auto retryableApi = makeRetry(apiCall, retryConfig); @@ -106,9 +104,7 @@ int main() { bool done = false; int attempt = 0; retryableApi->invoke( - "/api/data", - RunnableConfig(), - *dispatcher, + "/api/data", RunnableConfig(), *dispatcher, [&done, &attempt](Result result) { if (mcp::holds_alternative(result)) { std::cout << " Failed after retries: " @@ -138,20 +134,19 @@ int main() { { 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; - }); + 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); @@ -170,8 +165,7 @@ int main() { // Create fallback that returns cached data auto fallbackApi = makeLambda( - [](const std::string& endpoint, - Dispatcher& dispatcher, + [](const std::string& endpoint, Dispatcher& dispatcher, ResultCallback callback) { ApiResponse cached; cached.success = true; @@ -185,9 +179,7 @@ int main() { { bool done = false; safeApi->invoke( - "/api/unreliable", - RunnableConfig(), - *dispatcher, + "/api/unreliable", RunnableConfig(), *dispatcher, [&done](Result result) { if (mcp::holds_alternative(result)) { std::cout << " Error: " << mcp::get(result).message << "\n"; @@ -210,9 +202,9 @@ int main() { std::cout << "----------------------------------------\n"; auto cbConfig = CircuitBreakerConfig() - .withFailureThreshold(3) - .withSuccessThreshold(2) - .withTimeout(std::chrono::seconds(5)); + .withFailureThreshold(3) + .withSuccessThreshold(2) + .withTimeout(std::chrono::seconds(5)); // Reset client to 70% failure rate for circuit breaker demo client->setFailureRate(0.7); @@ -224,9 +216,7 @@ int main() { std::cout << " Call " << i << ": "; protectedApi->invoke( - "/api/fragile", - RunnableConfig(), - *dispatcher, + "/api/fragile", RunnableConfig(), *dispatcher, [&done](Result result) { if (mcp::holds_alternative(result)) { const auto& err = mcp::get(result); @@ -256,21 +246,18 @@ int main() { client->setFailureRate(0.3); auto combinedApi = makeFallback( - makeTimeout( - makeRetry(apiCall, RetryConfig().withMaxAttempts(2)), - std::chrono::milliseconds(300)), + makeTimeout(makeRetry(apiCall, RetryConfig().withMaxAttempts(2)), + std::chrono::milliseconds(300)), fallbackApi); { bool done = false; combinedApi->invoke( - "/api/important", - RunnableConfig(), - *dispatcher, + "/api/important", RunnableConfig(), *dispatcher, [&done](Result result) { if (mcp::holds_alternative(result)) { - std::cout << " Final error: " - << mcp::get(result).message << "\n"; + std::cout << " Final error: " << mcp::get(result).message + << "\n"; } else { auto& response = mcp::get(result); std::cout << " Got data: " << response.data << "\n"; diff --git a/examples/simple_agent/main.cc b/examples/simple_agent/main.cc index 5f7b4945..5fbbc453 100644 --- a/examples/simple_agent/main.cc +++ b/examples/simple_agent/main.cc @@ -3,10 +3,10 @@ // Demonstrates a basic AI agent that uses tools to answer questions. // The agent reasons about which tools to use and iterates until done. -#include "gopher/orch/orch.h" - #include +#include "gopher/orch/orch.h" + using namespace gopher::orch; using namespace gopher::orch::agent; using namespace gopher::orch::llm; @@ -44,13 +44,15 @@ int main(int argc, char* argv[]) { // 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; + 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); + return makeOrchError(OrchError::INVALID_ARGUMENT, + "Cannot evaluate: " + expr); } JsonValue response = JsonValue::object(); @@ -79,8 +81,7 @@ int main(int argc, char* argv[]) { // Search tool - async (simulated) registry->addTool( - "search", - "Search the web for information. Input: {\"query\": \"...\"}", + "search", "Search the web for information. Input: {\"query\": \"...\"}", JsonValue::object({{"query", "string"}}), [](const JsonValue& args, Dispatcher& d, JsonCallback cb) { auto query = args["query"].getString(); @@ -100,8 +101,7 @@ int main(int argc, char* argv[]) { // Step 3: Create Agent // ========================================================================= auto agent = makeAgentRunnable( - provider, - registry, + provider, registry, AgentConfig("gpt-4") .withSystemPrompt( "You are a helpful assistant with access to tools. " @@ -137,9 +137,7 @@ int main(int argc, char* argv[]) { bool done = false; agent->invoke( - JsonValue(query), - RunnableConfig(), - *dispatcher, + JsonValue(query), RunnableConfig(), *dispatcher, [&done](Result result) { if (mcp::holds_alternative(result)) { std::cerr << "Error: " << mcp::get(result).message << "\n"; diff --git a/examples/workflow/main.cc b/examples/workflow/main.cc index 2537d00b..2531f119 100644 --- a/examples/workflow/main.cc +++ b/examples/workflow/main.cc @@ -3,11 +3,11 @@ // Demonstrates a document processing workflow using StateGraph. // Shows conditional branching, node execution, and state management. -#include "gopher/orch/orch.h" - #include #include +#include "gopher/orch/orch.h" + using namespace gopher::orch; using namespace gopher::orch::graph; using namespace gopher::orch::core; @@ -25,13 +25,18 @@ struct DocumentState { static DocumentState merge(const DocumentState& base, const DocumentState& update) { DocumentState result = base; - if (!update.content.empty()) result.content = update.content; + 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; + 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; } }; @@ -78,9 +83,10 @@ DocumentState classifyDocument(const DocumentState& state, Dispatcher& d) { // 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.summary = + "Technical document summary: " + + state.content.substr(0, std::min(size_t(50), state.content.size())) + + "..."; update.keywords = {"technical", "documentation", "API"}; return update; } @@ -88,9 +94,10 @@ DocumentState summarizeTechnical(const DocumentState& state, Dispatcher& d) { // 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.summary = + "Legal document summary: " + + state.content.substr(0, std::min(size_t(50), state.content.size())) + + "..."; update.keywords = {"legal", "contract", "agreement"}; return update; } @@ -98,9 +105,10 @@ DocumentState summarizeLegal(const DocumentState& state, Dispatcher& d) { // 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.summary = + "General document summary: " + + state.content.substr(0, std::min(size_t(50), state.content.size())) + + "..."; update.keywords = {"general", "document"}; return update; } @@ -142,28 +150,28 @@ int main() { // | // 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(); + 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 @@ -187,9 +195,7 @@ int main() { bool done = false; graph->invoke( - initial, - RunnableConfig(), - *dispatcher, + initial, RunnableConfig(), *dispatcher, [&done](Result result) { if (mcp::holds_alternative(result)) { std::cerr << "Error: " << mcp::get(result).message << "\n"; @@ -200,7 +206,8 @@ int main() { std::cout << "Summary: " << state.summary << "\n"; std::cout << "Keywords: "; for (size_t j = 0; j < state.keywords.size(); j++) { - if (j > 0) std::cout << ", "; + if (j > 0) + std::cout << ", "; std::cout << state.keywords[j]; } std::cout << "\n"; From 4c1e3f1b44485c3686c6221a1787d77f1e2e55e8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Tue, 6 Jan 2026 21:15:01 -0800 Subject: [PATCH 184/197] Update upstream gopher-mcp submodule: 8cc9da6a0566f4ff648aef17127ac8de2fb71c25 --- third_party/gopher-mcp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp index 3563080b..8cc9da6a 160000 --- a/third_party/gopher-mcp +++ b/third_party/gopher-mcp @@ -1 +1 @@ -Subproject commit 3563080b7fc72ce36f143056c2c86af0a3e7cd59 +Subproject commit 8cc9da6a0566f4ff648aef17127ac8de2fb71c25 From 84eafc171e5479f96ccd46ac6719d7ec73dfa4c3 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 7 Jan 2026 23:22:50 -0800 Subject: [PATCH 185/197] Update upstream gopher-mcp submodule: bcd64eb4c105f7c52d0a2033651979914c07e8b7 --- third_party/gopher-mcp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp index 8cc9da6a..bcd64eb4 160000 --- a/third_party/gopher-mcp +++ b/third_party/gopher-mcp @@ -1 +1 @@ -Subproject commit 8cc9da6a0566f4ff648aef17127ac8de2fb71c25 +Subproject commit bcd64eb4c105f7c52d0a2033651979914c07e8b7 From a9598232615bd465c6597432f02c7fba8b3df02f Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 7 Jan 2026 23:26:43 -0800 Subject: [PATCH 186/197] Use compat.h for proper C++14/17 type handling (#35) Include mcp/core/compat.h instead of directly including optional.h and variant.h. The compat.h header handles the selection of std::optional/variant vs mcp::optional/variant based on the C++ standard being used. --- include/gopher/orch/core/types.h | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/include/gopher/orch/core/types.h b/include/gopher/orch/core/types.h index 693f30b7..99f17bf1 100644 --- a/include/gopher/orch/core/types.h +++ b/include/gopher/orch/core/types.h @@ -9,11 +9,10 @@ #include #include -// Use MCP core types for C++14 compatibility -#include "mcp/core/optional.h" +// 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/core/variant.h" #include "mcp/event/libevent_dispatcher.h" #include "mcp/json/json_bridge.h" #include "mcp/types.h" From 906bd670c2792aebf21169ef3770070438939ac6 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 7 Jan 2026 23:26:57 -0800 Subject: [PATCH 187/197] Add MCP_USE_STD_OPTIONAL_VARIANT=0 for ABI compatibility (#35) Define MCP_USE_STD_OPTIONAL_VARIANT=0 for gopher-orch targets to ensure they use mcp::optional/variant types, matching the gopher-mcp library ABI. Without this, gopher-orch would use std::optional/variant when compiled with C++17, causing linker errors due to symbol mismatch. --- src/CMakeLists.txt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4ba56bdb..153c08a4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -80,7 +80,12 @@ if(BUILD_STATIC_LIBS) Threads::Threads ) # Define GOPHER_ORCH_WITH_MCP to enable MCP-specific code - target_compile_definitions(gopher-orch-static PUBLIC GOPHER_ORCH_WITH_MCP) + # 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 @@ -137,7 +142,12 @@ if(BUILD_SHARED_LIBS) Threads::Threads ) # Define GOPHER_ORCH_WITH_MCP to enable MCP-specific code - target_compile_definitions(gopher-orch-shared PUBLIC GOPHER_ORCH_WITH_MCP) + # 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 From b213eede909d16a9c7b0d2a55993aba3014c29a8 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 7 Jan 2026 23:27:08 -0800 Subject: [PATCH 188/197] Fix ambiguous make_optional call in config.h (#35) Explicitly qualify make_optional with mcp:: namespace to avoid ambiguity with std::make_optional when compiling with C++17. --- include/gopher/orch/core/config.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/include/gopher/orch/core/config.h b/include/gopher/orch/core/config.h index e5a794f5..22f88aca 100644 --- a/include/gopher/orch/core/config.h +++ b/include/gopher/orch/core/config.h @@ -73,7 +73,8 @@ class RunnableConfig { optional tag(const std::string& key) const { auto it = tags_.find(key); if (it != tags_.end()) { - return make_optional(it->second); + // Explicit namespace to avoid ambiguity with std::make_optional in C++17 + return mcp::make_optional(it->second); } return nullopt; } From 392ab2dcff94453ed512ff6d0971b5f6c9af3608 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 7 Jan 2026 23:27:17 -0800 Subject: [PATCH 189/197] Fix ambiguous make_optional calls in mcp_client example (#35) Explicitly qualify make_optional with mcp:: namespace to avoid ambiguity with std::make_optional when compiling with C++17. --- examples/mcp_client/mcp_client_example.cc | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/mcp_client/mcp_client_example.cc b/examples/mcp_client/mcp_client_example.cc index 28575339..5cc1937a 100644 --- a/examples/mcp_client/mcp_client_example.cc +++ b/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; From 1643057013085a1df104c4fed23158dd0ca61a38 Mon Sep 17 00:00:00 2001 From: gophergogo Date: Wed, 7 Jan 2026 23:28:34 -0800 Subject: [PATCH 190/197] make format code to apply clang-format (#35) --- examples/mcp_client/mcp_client_example.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/mcp_client/mcp_client_example.cc b/examples/mcp_client/mcp_client_example.cc index 5cc1937a..6d43193e 100644 --- a/examples/mcp_client/mcp_client_example.cc +++ b/examples/mcp_client/mcp_client_example.cc @@ -77,7 +77,8 @@ int main(int argc, char* argv[]) { sample_resource.name = "Example Data"; sample_resource.description = mcp::make_optional(std::string("Sample JSON data resource for testing")); - sample_resource.mimeType = mcp::make_optional(std::string("application/json")); + 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; From 337087927310abbd4e104b79db792a7d0192f96a Mon Sep 17 00:00:00 2001 From: RahulHere Date: Wed, 14 Jan 2026 23:31:21 +0800 Subject: [PATCH 191/197] Remove all C++ files to prepare for TypeScript SDK (#1) This commit removes all C++ source files, headers, examples, tests, and build configurations from gopher-orch-js in preparation for converting it to a pure TypeScript SDK. Removed: - All C++ source files (src/) - All C++ headers (include/) - All C++ examples (examples/) - All C++ tests (tests/) - CMake build system (CMakeLists.txt, cmake/, build.sh) - C++ specific configs (.clang-format, Makefile) - C++ documentation (docs/) - gopher-mcp submodule (third_party/) --- .clang-format | 53 - .github/workflows/pr-format-check.yml | 86 -- .gitignore | 109 -- .gitmodules | 4 - CMakeLists.txt | 253 ---- LICENSE | 201 --- Makefile | 388 ----- README.md | 382 ----- build.sh | 123 -- cmake/cmake_uninstall.cmake.in | 49 - cmake/gopher-orch-config.cmake.in | 23 - docs/Agent.md | 497 ------ docs/AgentRunnable.md | 863 ----------- docs/Composition.md | 258 ---- docs/FFI.md | 414 ----- docs/LLMProvider.md | 331 ---- docs/Resilience.md | 323 ---- docs/Runnable.md | 222 --- docs/Server.md | 301 ---- docs/StateGraph.md | 305 ---- docs/ToolRegistry.md | 485 ------ examples/CMakeLists.txt | 7 - examples/chatbot/README.md | 109 -- examples/chatbot/main.cc | 154 -- examples/hello_world/CMakeLists.txt | 15 - examples/hello_world/main.cpp | 78 - examples/mcp_client/CMakeLists.txt | 32 - examples/mcp_client/mcp_client_example.cc | 153 -- examples/multi_agent/README.md | 159 -- examples/multi_agent/main.cc | 257 ---- examples/resilient_api/README.md | 127 -- examples/resilient_api/main.cc | 277 ---- examples/simple_agent/README.md | 73 - examples/simple_agent/main.cc | 165 -- examples/workflow/README.md | 151 -- examples/workflow/main.cc | 230 --- include/gopher/orch/agent/agent.h | 170 --- include/gopher/orch/agent/agent_module.h | 67 - include/gopher/orch/agent/agent_runnable.h | 216 --- include/gopher/orch/agent/agent_types.h | 484 ------ include/gopher/orch/agent/config_loader.h | 438 ------ include/gopher/orch/agent/rest_tool_adapter.h | 294 ---- include/gopher/orch/agent/tool_definition.h | 354 ----- include/gopher/orch/agent/tool_executor.h | 144 -- include/gopher/orch/agent/tool_registry.h | 427 ------ include/gopher/orch/agent/tool_runnable.h | 128 -- .../gopher/orch/callback/callback_handler.h | 292 ---- .../gopher/orch/callback/callback_manager.h | 483 ------ include/gopher/orch/composition/parallel.h | 183 --- include/gopher/orch/composition/router.h | 147 -- include/gopher/orch/composition/sequence.h | 207 --- include/gopher/orch/core/config.h | 146 -- 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 | 1341 ----------------- include/gopher/orch/ffi/orch_ffi_bridge.h | 854 ----------- include/gopher/orch/ffi/orch_ffi_raii.h | 554 ------- include/gopher/orch/ffi/orch_ffi_types.h | 557 ------- include/gopher/orch/fsm/state_machine.h | 335 ---- include/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 ------ include/gopher/orch/llm/anthropic_provider.h | 139 -- 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 -- 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 -- include/gopher/orch/server/mcp_server.h | 200 --- include/gopher/orch/server/mock_server.h | 274 ---- include/gopher/orch/server/rest_server.h | 325 ---- include/gopher/orch/server/server.h | 142 -- include/gopher/orch/server/server_composite.h | 412 ----- include/orch/core/hello.h | 46 - include/orch/core/version.h | 22 - src/CMakeLists.txt | 187 --- src/gopher/orch/agent/agent.cc | 430 ------ src/gopher/orch/agent/agent_runnable.cc | 499 ------ src/gopher/orch/agent/config_loader.cc | 75 - src/gopher/orch/agent/tool_registry.cc | 322 ---- src/gopher/orch/agent/tool_runnable.cc | 213 --- src/gopher/orch/llm/anthropic_provider.cc | 420 ------ src/gopher/orch/llm/llm_factory.cc | 60 - src/gopher/orch/llm/llm_runnable.cc | 248 --- src/gopher/orch/llm/openai_provider.cc | 412 ----- src/gopher/orch/server/mcp_server.cc | 477 ------ src/gopher/orch/server/rest_server.cc | 418 ----- src/orch/hello.cc | 59 - tests/CMakeLists.txt | 143 -- 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 ------ tests/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 -- tests/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 ----- tests/orch/hello_test.cpp | 110 -- third_party/gopher-mcp | 1 - 134 files changed, 32572 deletions(-) delete mode 100644 .clang-format delete mode 100644 .github/workflows/pr-format-check.yml delete mode 100644 .gitignore delete mode 100644 .gitmodules delete mode 100644 CMakeLists.txt delete mode 100644 LICENSE delete mode 100644 Makefile delete mode 100644 README.md delete mode 100755 build.sh delete mode 100644 cmake/cmake_uninstall.cmake.in delete mode 100644 cmake/gopher-orch-config.cmake.in delete mode 100644 docs/Agent.md delete mode 100644 docs/AgentRunnable.md delete mode 100644 docs/Composition.md delete mode 100644 docs/FFI.md delete mode 100644 docs/LLMProvider.md delete mode 100644 docs/Resilience.md delete mode 100644 docs/Runnable.md delete mode 100644 docs/Server.md delete mode 100644 docs/StateGraph.md delete mode 100644 docs/ToolRegistry.md delete mode 100644 examples/CMakeLists.txt delete mode 100644 examples/chatbot/README.md delete mode 100644 examples/chatbot/main.cc delete mode 100644 examples/hello_world/CMakeLists.txt delete mode 100644 examples/hello_world/main.cpp delete mode 100644 examples/mcp_client/CMakeLists.txt delete mode 100644 examples/mcp_client/mcp_client_example.cc delete mode 100644 examples/multi_agent/README.md delete mode 100644 examples/multi_agent/main.cc delete mode 100644 examples/resilient_api/README.md delete mode 100644 examples/resilient_api/main.cc delete mode 100644 examples/simple_agent/README.md delete mode 100644 examples/simple_agent/main.cc delete mode 100644 examples/workflow/README.md delete mode 100644 examples/workflow/main.cc delete mode 100644 include/gopher/orch/agent/agent.h delete mode 100644 include/gopher/orch/agent/agent_module.h delete mode 100644 include/gopher/orch/agent/agent_runnable.h delete mode 100644 include/gopher/orch/agent/agent_types.h delete mode 100644 include/gopher/orch/agent/config_loader.h delete mode 100644 include/gopher/orch/agent/rest_tool_adapter.h delete mode 100644 include/gopher/orch/agent/tool_definition.h delete mode 100644 include/gopher/orch/agent/tool_executor.h delete mode 100644 include/gopher/orch/agent/tool_registry.h delete mode 100644 include/gopher/orch/agent/tool_runnable.h delete mode 100644 include/gopher/orch/callback/callback_handler.h delete mode 100644 include/gopher/orch/callback/callback_manager.h delete mode 100644 include/gopher/orch/composition/parallel.h delete mode 100644 include/gopher/orch/composition/router.h delete mode 100644 include/gopher/orch/composition/sequence.h delete mode 100644 include/gopher/orch/core/config.h delete mode 100644 include/gopher/orch/core/lambda.h delete mode 100644 include/gopher/orch/core/runnable.h delete mode 100644 include/gopher/orch/core/types.h delete mode 100644 include/gopher/orch/ffi/orch_ffi.h delete mode 100644 include/gopher/orch/ffi/orch_ffi_bridge.h delete mode 100644 include/gopher/orch/ffi/orch_ffi_raii.h delete mode 100644 include/gopher/orch/ffi/orch_ffi_types.h delete mode 100644 include/gopher/orch/fsm/state_machine.h delete mode 100644 include/gopher/orch/graph/compiled_graph.h delete mode 100644 include/gopher/orch/graph/graph_node.h delete mode 100644 include/gopher/orch/graph/graph_state.h delete mode 100644 include/gopher/orch/graph/state_graph.h delete mode 100644 include/gopher/orch/human/approval.h delete mode 100644 include/gopher/orch/llm/anthropic_provider.h delete mode 100644 include/gopher/orch/llm/llm.h delete mode 100644 include/gopher/orch/llm/llm_provider.h delete mode 100644 include/gopher/orch/llm/llm_runnable.h delete mode 100644 include/gopher/orch/llm/llm_types.h delete mode 100644 include/gopher/orch/llm/openai_provider.h delete mode 100644 include/gopher/orch/orch.h delete mode 100644 include/gopher/orch/resilience/circuit_breaker.h delete mode 100644 include/gopher/orch/resilience/fallback.h delete mode 100644 include/gopher/orch/resilience/retry.h delete mode 100644 include/gopher/orch/resilience/timeout.h delete mode 100644 include/gopher/orch/server/mcp_server.h delete mode 100644 include/gopher/orch/server/mock_server.h delete mode 100644 include/gopher/orch/server/rest_server.h delete mode 100644 include/gopher/orch/server/server.h delete mode 100644 include/gopher/orch/server/server_composite.h delete mode 100644 include/orch/core/hello.h delete mode 100644 include/orch/core/version.h delete mode 100644 src/CMakeLists.txt delete mode 100644 src/gopher/orch/agent/agent.cc delete mode 100644 src/gopher/orch/agent/agent_runnable.cc delete mode 100644 src/gopher/orch/agent/config_loader.cc delete mode 100644 src/gopher/orch/agent/tool_registry.cc delete mode 100644 src/gopher/orch/agent/tool_runnable.cc delete mode 100644 src/gopher/orch/llm/anthropic_provider.cc delete mode 100644 src/gopher/orch/llm/llm_factory.cc delete mode 100644 src/gopher/orch/llm/llm_runnable.cc delete mode 100644 src/gopher/orch/llm/openai_provider.cc delete mode 100644 src/gopher/orch/server/mcp_server.cc delete mode 100644 src/gopher/orch/server/rest_server.cc delete mode 100644 src/orch/hello.cc delete mode 100644 tests/CMakeLists.txt delete mode 100644 tests/gopher/orch/FFI/ffi_builder_test.cc delete mode 100644 tests/gopher/orch/FFI/ffi_core_test.cc delete mode 100644 tests/gopher/orch/FFI/ffi_error_test.cc delete mode 100644 tests/gopher/orch/FFI/ffi_handle_test.cc delete mode 100644 tests/gopher/orch/FFI/ffi_json_test.cc delete mode 100644 tests/gopher/orch/FFI/ffi_lambda_test.cc delete mode 100644 tests/gopher/orch/FFI/ffi_raii_test.cc delete mode 100644 tests/gopher/orch/FFI/ffi_types_test.cc delete mode 100644 tests/gopher/orch/agent_runnable_test.cc delete mode 100644 tests/gopher/orch/agent_state_test.cc delete mode 100644 tests/gopher/orch/agent_test.cc delete mode 100644 tests/gopher/orch/callback_manager_test.cc delete mode 100644 tests/gopher/orch/circuit_breaker_test.cc delete mode 100644 tests/gopher/orch/fallback_test.cc delete mode 100644 tests/gopher/orch/human_approval_test.cc delete mode 100644 tests/gopher/orch/integration_test.cc delete mode 100644 tests/gopher/orch/lambda_test.cc delete mode 100644 tests/gopher/orch/llm_provider_test.cc delete mode 100644 tests/gopher/orch/llm_runnable_test.cc delete mode 100644 tests/gopher/orch/mcp_server_test.cc delete mode 100644 tests/gopher/orch/mock_http_client.h delete mode 100644 tests/gopher/orch/mock_llm_provider.h delete mode 100644 tests/gopher/orch/mock_server_test.cc delete mode 100644 tests/gopher/orch/orch_test_fixture.h delete mode 100644 tests/gopher/orch/parallel_test.cc delete mode 100644 tests/gopher/orch/rest_server_test.cc delete mode 100644 tests/gopher/orch/retry_test.cc delete mode 100644 tests/gopher/orch/router_test.cc delete mode 100644 tests/gopher/orch/sequence_test.cc delete mode 100644 tests/gopher/orch/server_composite_test.cc delete mode 100644 tests/gopher/orch/state_graph_test.cc delete mode 100644 tests/gopher/orch/state_machine_test.cc delete mode 100644 tests/gopher/orch/timeout_test.cc delete mode 100644 tests/gopher/orch/tool_registry_test.cc delete mode 100644 tests/gopher/orch/tool_runnable_test.cc delete mode 100644 tests/orch/hello_test.cpp delete mode 160000 third_party/gopher-mcp diff --git a/.clang-format b/.clang-format deleted file mode 100644 index bb00198a..00000000 --- a/.clang-format +++ /dev/null @@ -1,53 +0,0 @@ ---- -# Google C++ Style Guide -# https://google.github.io/styleguide/cppguide.html -BasedOnStyle: Google -IndentWidth: 2 -ColumnLimit: 80 ---- -Language: Cpp -# Force pointers to the type for C++. -DerivePointerAlignment: false -PointerAlignment: Left -# Other adjustments -AccessModifierOffset: -1 -AllowShortFunctionsOnASingleLine: All -AllowShortIfStatementsOnASingleLine: false -AllowShortLoopsOnASingleLine: false -AlwaysBreakTemplateDeclarations: true -BinPackParameters: false -BreakBeforeBraces: Attach -BreakConstructorInitializers: BeforeColon -ConstructorInitializerAllOnOneLineOrOnePerLine: true -Cpp11BracedListStyle: true -IncludeBlocks: Regroup -IncludeCategories: - # Standard library headers - - Regex: '^<[^/]+>$' - Priority: 1 - # Other library headers - - Regex: '^<.+>$' - Priority: 2 - # Project headers with quotes - - Regex: '^"mcp/.+"$' - Priority: 3 - # Other project headers - - Regex: '^".+"$' - Priority: 4 -IndentCaseLabels: true -KeepEmptyLinesAtTheStartOfBlocks: false -NamespaceIndentation: None -SortIncludes: true -SpaceAfterCStyleCast: false -SpaceAfterTemplateKeyword: true -SpaceBeforeAssignmentOperators: true -SpaceBeforeParens: ControlStatements -SpaceInEmptyParentheses: false -SpacesInAngles: false -SpacesInCStyleCastParentheses: false -SpacesInParentheses: false -SpacesInSquareBrackets: false -Standard: c++14 -UseTab: Never -# Remove trailing whitespace -InsertTrailingCommas: None \ No newline at end of file diff --git a/.github/workflows/pr-format-check.yml b/.github/workflows/pr-format-check.yml deleted file mode 100644 index 769b322b..00000000 --- a/.github/workflows/pr-format-check.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: PR Format Check - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - clang-format: - name: Clang Format - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - - steps: - - name: Checkout PR - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - - - name: Install clang-format - run: | - sudo apt-get update - sudo apt-get install -y clang-format-14 - - - name: Check changed files - id: changed-files - run: | - # Get list of changed C/C++ files (excluding submodules) - git diff --name-only origin/${{ github.base_ref }}...HEAD | \ - grep -E '\.(h|hpp|c|cc|cpp)$' | \ - grep -v '^third_party/' > changed_files.txt || true - - if [ -s changed_files.txt ]; then - echo "has_changes=true" >> $GITHUB_OUTPUT - echo "Changed C/C++ files:" - cat changed_files.txt - else - echo "has_changes=false" >> $GITHUB_OUTPUT - echo "No C/C++ files changed" - fi - - - name: Check formatting of changed files - if: steps.changed-files.outputs.has_changes == 'true' - run: | - exit_code=0 - while IFS= read -r file; do - if [ -f "$file" ]; then - echo "Checking $file..." - clang-format-14 --style=file --dry-run --Werror "$file" || { - echo "::error file=$file::File is not properly formatted" - exit_code=1 - } - fi - done < changed_files.txt - - if [ $exit_code -ne 0 ]; then - echo "" - echo "::error::Some files are not properly formatted." - echo "To fix, run: make format" - exit 1 - fi - - - name: Post PR comment on failure - if: failure() && steps.changed-files.outputs.has_changes == 'true' - uses: actions/github-script@v7 - with: - script: | - const comment = `## Code Formatting Check Failed - - Some files in this PR are not properly formatted according to the project's clang-format rules. - - **To fix this issue:** - \`\`\`bash - make format - \`\`\` - - Then commit and push the changes.`; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 457c7687..00000000 --- a/.gitignore +++ /dev/null @@ -1,109 +0,0 @@ -# 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 \ No newline at end of file diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index f7c1a54e..00000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "third_party/gopher-mcp"] - path = third_party/gopher-mcp - url = https://github.com/GopherSecurity/gopher-mcp.git - branch = main diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 80c90036..00000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,253 +0,0 @@ -cmake_minimum_required(VERSION 3.10) -project(gopher-orch VERSION 0.1.0 LANGUAGES C CXX) - -# Prevent in-source builds -if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) - message(FATAL_ERROR "In-source builds are not allowed. Please create a build directory and run cmake from there.") -endif() - -# Set C++ standard -set(CMAKE_CXX_STANDARD 14) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) -message(STATUS "Using C++14") - -# Default to Debug build -if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE) -endif() - -# Build options -option(BUILD_SHARED_LIBS "Build shared libraries" ON) -option(BUILD_STATIC_LIBS "Build static libraries" ON) -option(BUILD_TESTS "Build tests" ON) -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) - -# Set output directories -set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) - -# Compiler flags -if(CMAKE_BUILD_TYPE STREQUAL "Debug") - add_compile_options(-g -O0) - add_compile_definitions(_DEBUG) -elseif(CMAKE_BUILD_TYPE STREQUAL "Release") - add_compile_options(-O3) - add_compile_definitions(NDEBUG) -endif() - -# Platform-specific settings -if(APPLE) - set(CMAKE_MACOSX_RPATH ON) - set(CMAKE_INSTALL_RPATH "@loader_path/../lib") -elseif(UNIX) - set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib") -endif() - -# Warning flags -if(ORCH_STRICT_WARNINGS) - if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - add_compile_options( - -Wall -Wextra -Wpedantic - -Wno-unused-parameter - -Wno-unused-variable - -Wno-unused-function - -Werror - ) - elseif(MSVC) - add_compile_options(/W4 /WX) - endif() -else() - if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - add_compile_options( - -Wall - -Wno-unused-parameter - -Wno-unused-variable - -Wno-unused-function - ) - endif() -endif() - -# Handle gopher-mcp dependency -if(BUILD_WITHOUT_GOPHER_MCP) - # Build without gopher-mcp for testing - message(STATUS "Building without gopher-mcp dependency") - set(GOPHER_MCP_LIBRARIES "") - set(GOPHER_MCP_INCLUDE_DIR "") -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() - else() - 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 - set(GOPHER_MCP_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/third_party/gopher-mcp/include) - - # Temporarily add gopher-mcp include directories before processing subdirectory - # This ensures gopher-mcp can find its own headers when building as submodule - include_directories(${GOPHER_MCP_INCLUDE_DIR}) - - # Disable gopher-mcp tests and examples to speed up build - set(BUILD_TESTS_SAVED ${BUILD_TESTS}) - set(BUILD_EXAMPLES_SAVED ${BUILD_EXAMPLES}) - set(BUILD_TESTS OFF CACHE BOOL "" FORCE) - set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) - set(BUILD_BINDINGS_EXAMPLES OFF CACHE BOOL "" FORCE) - - # Disable fmt installation if gopher-mcp uses it - set(FMT_INSTALL OFF CACHE BOOL "Disable fmt installation" FORCE) - - # Add gopher-mcp subdirectory - add_subdirectory(third_party/gopher-mcp EXCLUDE_FROM_ALL) - - # Restore our settings - set(BUILD_TESTS ${BUILD_TESTS_SAVED} CACHE BOOL "" FORCE) - set(BUILD_EXAMPLES ${BUILD_EXAMPLES_SAVED} CACHE BOOL "" FORCE) - - # Make gopher-mcp libraries available - # Use static libraries for tests to avoid duplicate initialization - if(BUILD_TESTS AND TARGET gopher-mcp-static) - set(GOPHER_MCP_LIBRARIES gopher-mcp-static gopher-mcp-event-static) - else() - set(GOPHER_MCP_LIBRARIES gopher-mcp gopher-mcp-event) - endif() - - message(STATUS "Using gopher-mcp from submodule") -else() - # Use system-installed gopher-mcp - find_package(gopher-mcp REQUIRED) - message(STATUS "Using system gopher-mcp: ${gopher-mcp_DIR}") -endif() - -# Include directories -message(STATUS "GOPHER_MCP_INCLUDE_DIR: ${GOPHER_MCP_INCLUDE_DIR}") -include_directories( - ${CMAKE_SOURCE_DIR}/include - ${GOPHER_MCP_INCLUDE_DIR} -) - -# Export compile commands for tools like clangd -set(CMAKE_EXPORT_COMPILE_COMMANDS ON) - -# Find required packages -find_package(Threads REQUIRED) - -# Testing setup -if(BUILD_TESTS) - enable_testing() - include(CTest) - - # Fetch Google Test - include(FetchContent) - - # Prevent Google Test from being installed - set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) - set(INSTALL_GMOCK OFF CACHE BOOL "Disable installation of googlemock" FORCE) - set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) - - FetchContent_Declare( - googletest - GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.14.0 - CMAKE_ARGS -DINSTALL_GTEST=OFF -DINSTALL_GMOCK=OFF - ) - - FetchContent_MakeAvailable(googletest) - - # Include Google Test and Google Mock - include(GoogleTest) -endif() - -# Add subdirectories -add_subdirectory(src) - -if(BUILD_TESTS) - add_subdirectory(tests) -endif() - -if(BUILD_EXAMPLES) - add_subdirectory(examples) -endif() - -# Installation rules -install(DIRECTORY include/orch - DESTINATION include - COMPONENT development - FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" -) - -# Package configuration -include(CMakePackageConfigHelpers) - -configure_package_config_file( - "${CMAKE_CURRENT_SOURCE_DIR}/cmake/gopher-orch-config.cmake.in" - "${CMAKE_CURRENT_BINARY_DIR}/gopher-orch-config.cmake" - INSTALL_DESTINATION lib/cmake/gopher-orch -) - -write_basic_package_version_file( - "${CMAKE_CURRENT_BINARY_DIR}/gopher-orch-config-version.cmake" - VERSION ${PROJECT_VERSION} - COMPATIBILITY SameMajorVersion -) - -install(FILES - "${CMAKE_CURRENT_BINARY_DIR}/gopher-orch-config.cmake" - "${CMAKE_CURRENT_BINARY_DIR}/gopher-orch-config-version.cmake" - DESTINATION lib/cmake/gopher-orch - COMPONENT development -) - -# Add uninstall target -if(NOT TARGET uninstall) - configure_file( - "${CMAKE_CURRENT_SOURCE_DIR}/cmake/cmake_uninstall.cmake.in" - "${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake" - IMMEDIATE @ONLY - ) - - add_custom_target(uninstall - COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake - ) -endif() - -# Print configuration summary -message(STATUS "") -message(STATUS "=== gopher-orch Configuration Summary ===") -message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") -message(STATUS "C++ Standard: ${CMAKE_CXX_STANDARD}") -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 "Strict warnings: ${ORCH_STRICT_WARNINGS}") -message(STATUS "Use submodule gopher-mcp: ${USE_SUBMODULE_GOPHER_MCP}") -message(STATUS "Install prefix: ${CMAKE_INSTALL_PREFIX}") -message(STATUS "==========================================") -message(STATUS "") diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9e..00000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - 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/Makefile deleted file mode 100644 index bbf77bbe..00000000 --- a/Makefile +++ /dev/null @@ -1,388 +0,0 @@ -# gopher-orch Makefile -# Consolidates all CMake commands for easy building - -# Build configuration -BUILD_DIR ?= build -BUILD_TYPE ?= Debug -GENERATOR ?= "Unix Makefiles" -PARALLEL_JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) - -# Library build options (both by default) -BUILD_STATIC ?= ON -BUILD_SHARED ?= ON - -# CMake options -CMAKE_OPTIONS ?= -VERBOSE ?= 0 - -# Colors for output -RED := \033[0;31m -GREEN := \033[0;32m -YELLOW := \033[1;33m -BLUE := \033[0;34m -NC := \033[0m # No Color - -# Default target -.PHONY: all -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: init-submodules - @echo "$(BLUE)Configuring with CMake...$(NC)" - @echo " Build type: $(BUILD_TYPE)" - @echo " Static library: $(BUILD_STATIC)" - @echo " Shared library: $(BUILD_SHARED)" - @mkdir -p $(BUILD_DIR) - @cd $(BUILD_DIR) && cmake .. -G $(GENERATOR) \ - -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DBUILD_STATIC_LIBS=$(BUILD_STATIC) \ - -DBUILD_SHARED_LIBS=$(BUILD_SHARED) \ - $(CMAKE_OPTIONS) - @echo "$(GREEN)Configuration complete$(NC)" - -# Build the project -.PHONY: build -build: configure - @echo "$(BLUE)Building gopher-orch libraries...$(NC)" - @cmake --build $(BUILD_DIR) -- -j$(PARALLEL_JOBS) - @echo "$(GREEN)Build complete$(NC)" - @$(MAKE) --no-print-directory lib-info-summary - -# Build in release mode -.PHONY: release -release: - @echo "$(BLUE)Building in Release mode...$(NC)" - @$(MAKE) BUILD_TYPE=Release build test - @echo "$(GREEN)Release build complete$(NC)" - -# Build in debug mode (explicit) -.PHONY: debug -debug: - @echo "$(BLUE)Building in Debug mode...$(NC)" - @$(MAKE) BUILD_TYPE=Debug build - @echo "$(GREEN)Debug build complete$(NC)" - -# Run tests -.PHONY: test -test: build - @echo "$(BLUE)Running tests...$(NC)" - @cd $(BUILD_DIR) && ctest --output-on-failure - @echo "$(GREEN)All tests passed$(NC)" - -# Run tests with verbose output -.PHONY: test-verbose -test-verbose: build - @echo "$(BLUE)Running tests (verbose)...$(NC)" - @cd $(BUILD_DIR) && ctest -V - @echo "$(GREEN)All tests passed$(NC)" - -# Run tests in parallel -.PHONY: test-parallel -test-parallel: build - @echo "$(BLUE)Running tests in parallel...$(NC)" - @cd $(BUILD_DIR) && ctest -j$(PARALLEL_JOBS) --output-on-failure - @echo "$(GREEN)All tests passed$(NC)" - -# Run specific test -.PHONY: test-one -test-one: build - @if [ -z "$(TEST)" ]; then \ - echo "$(RED)Error: TEST variable not set. Usage: make test-one TEST=test_name$(NC)"; \ - exit 1; \ - fi - @echo "$(BLUE)Running test: $(TEST)...$(NC)" - @cd $(BUILD_DIR) && ctest -R $(TEST) -V - @echo "$(GREEN)Test complete$(NC)" - -# Build only the libraries (respects current configuration) -.PHONY: libs -libs: configure - @echo "$(BLUE)Building libraries...$(NC)" - @if [ -f $(BUILD_DIR)/CMakeCache.txt ]; then \ - if grep -q "BUILD_STATIC_LIBS:BOOL=ON" $(BUILD_DIR)/CMakeCache.txt 2>/dev/null; then \ - cmake --build $(BUILD_DIR) --target gopher-orch-static -- -j$(PARALLEL_JOBS); \ - fi; \ - if grep -q "BUILD_SHARED_LIBS:BOOL=ON" $(BUILD_DIR)/CMakeCache.txt 2>/dev/null; then \ - cmake --build $(BUILD_DIR) --target gopher-orch-shared -- -j$(PARALLEL_JOBS); \ - fi; \ - else \ - cmake --build $(BUILD_DIR) --target gopher-orch-static -- -j$(PARALLEL_JOBS); \ - fi - @echo "$(GREEN)Libraries built$(NC)" - -# Build only the examples -.PHONY: examples -examples: libs - @echo "$(BLUE)Building examples...$(NC)" - @cmake --build $(BUILD_DIR) --target hello_world_example -- -j$(PARALLEL_JOBS) - @echo "$(GREEN)Examples built$(NC)" - -# Run the hello world example -.PHONY: run-hello -run-hello: examples - @echo "$(BLUE)Running hello_world_example...$(NC)" - @$(BUILD_DIR)/bin/hello_world_example - @echo "$(GREEN)Example completed$(NC)" - -# Clean build directory -.PHONY: clean -clean: - @echo "$(YELLOW)Cleaning build directory...$(NC)" - @rm -rf $(BUILD_DIR) - @echo "$(GREEN)Clean complete$(NC)" - -# Deep clean (including submodules) -.PHONY: distclean -distclean: clean - @echo "$(YELLOW)Deep cleaning...$(NC)" - @git submodule deinit -f . - @rm -rf third_party/gopher-mcp - @rm -rf .git/modules/third_party - @echo "$(GREEN)Deep clean complete$(NC)" - -# Format all source files -.PHONY: format -format: - @echo "$(BLUE)Formatting all source files with clang-format...$(NC)" - @find . -path "./$(BUILD_DIR)*" -prune -o -path "./third_party" -prune -o \ - \( -name "*.h" -o -name "*.hpp" -o -name "*.cpp" -o -name "*.cc" -o -name "*.c" \) -print | \ - xargs clang-format -i - @echo "$(GREEN)Formatting complete$(NC)" - -# Check formatting without modifying files -.PHONY: check-format -check-format: - @echo "$(BLUE)Checking source file formatting...$(NC)" - @find . -path "./$(BUILD_DIR)*" -prune -o -path "./third_party" -prune -o \ - \( -name "*.h" -o -name "*.hpp" -o -name "*.cpp" -o -name "*.cc" -o -name "*.c" \) -print | \ - xargs clang-format --dry-run --Werror - @if [ $$? -eq 0 ]; then \ - echo "$(GREEN)All files are properly formatted$(NC)"; \ - else \ - echo "$(RED)Format check failed - run 'make format' to fix$(NC)"; \ - exit 1; \ - fi - -# Alias for consistency with gopher-mcp -.PHONY: format-check -format-check: check-format - -# Install the library -.PHONY: install -install: build - @echo "$(BLUE)Installing gopher-orch...$(NC)" - @cmake --build $(BUILD_DIR) --target install - @echo "$(GREEN)Installation complete$(NC)" - -# Uninstall the library -.PHONY: uninstall -uninstall: - @echo "$(YELLOW)Uninstalling gopher-orch...$(NC)" - @if [ ! -f $(BUILD_DIR)/install_manifest.txt ]; then \ - echo "$(RED)Error: No installation found. Run 'make install' first.$(NC)"; \ - exit 1; \ - fi - @cmake --build $(BUILD_DIR) --target uninstall - @echo "$(GREEN)Uninstall complete$(NC)" - -# Generate documentation (requires doxygen) -.PHONY: docs -docs: - @echo "$(BLUE)Generating documentation...$(NC)" - @doxygen Doxyfile 2>/dev/null || echo "$(YELLOW)Warning: Doxygen not found or configured$(NC)" - @echo "$(GREEN)Documentation generated$(NC)" - -# Update submodules -.PHONY: update-submodules -update-submodules: - @echo "$(BLUE)Updating submodules...$(NC)" - @git submodule update --init --recursive - @echo "$(GREEN)Submodules updated$(NC)" - -# Configure to use system gopher-mcp instead of submodule -.PHONY: use-system-gopher-mcp -use-system-gopher-mcp: - @echo "$(BLUE)Configuring to use system gopher-mcp...$(NC)" - @$(MAKE) CMAKE_OPTIONS="-DUSE_SUBMODULE_GOPHER_MCP=OFF" configure - @echo "$(GREEN)Configured to use system gopher-mcp$(NC)" - -# Configure to use submodule gopher-mcp -.PHONY: use-submodule-gopher-mcp -use-submodule-gopher-mcp: - @echo "$(BLUE)Configuring to use submodule gopher-mcp...$(NC)" - @$(MAKE) CMAKE_OPTIONS="-DUSE_SUBMODULE_GOPHER_MCP=ON" configure - @echo "$(GREEN)Configured to use submodule gopher-mcp$(NC)" - -# Build shared library only -.PHONY: shared -shared: - @$(MAKE) BUILD_STATIC=OFF BUILD_SHARED=ON clean build - -# Build static library only -.PHONY: static -static: - @$(MAKE) BUILD_STATIC=ON BUILD_SHARED=OFF clean build - -# Build both static and shared libraries (default behavior) -.PHONY: both -both: - @$(MAKE) BUILD_STATIC=ON BUILD_SHARED=ON clean build - -# Build standalone (without gopher-mcp dependency) -.PHONY: standalone -standalone: - @echo "$(BLUE)Building standalone (without gopher-mcp)...$(NC)" - @$(MAKE) CMAKE_OPTIONS="-DBUILD_WITHOUT_GOPHER_MCP=ON" build - @echo "$(GREEN)Standalone build complete$(NC)" - -# Show brief library summary (used after build) -.PHONY: lib-info-summary -lib-info-summary: - @if [ -f $(BUILD_DIR)/lib/libgopher-orch.a ]; then \ - echo " $(GREEN)Static library: $(BUILD_DIR)/lib/libgopher-orch.a ($$(du -h $(BUILD_DIR)/lib/libgopher-orch.a 2>/dev/null | cut -f1))$(NC)"; \ - fi - @if [ -f $(BUILD_DIR)/lib/libgopher-orch.so ]; then \ - echo " $(GREEN)Shared library: $(BUILD_DIR)/lib/libgopher-orch.so ($$(du -h $(BUILD_DIR)/lib/libgopher-orch.so 2>/dev/null | cut -f1))$(NC)"; \ - elif [ -f $(BUILD_DIR)/lib/libgopher-orch.dylib ]; then \ - echo " $(GREEN)Shared library: $(BUILD_DIR)/lib/libgopher-orch.dylib ($$(du -h $(BUILD_DIR)/lib/libgopher-orch.dylib 2>/dev/null | cut -f1))$(NC)"; \ - fi - -# Show detailed library information -.PHONY: lib-info -lib-info: - @echo "$(BLUE)Library Information:$(NC)" - @if [ -f $(BUILD_DIR)/lib/libgopher-orch.a ]; then \ - echo "$(GREEN)Static library:$(NC)"; \ - echo " Path: $(BUILD_DIR)/lib/libgopher-orch.a"; \ - echo " Size: $$(du -h $(BUILD_DIR)/lib/libgopher-orch.a | cut -f1)"; \ - if command -v ar >/dev/null 2>&1; then \ - echo " Objects: $$(ar -t $(BUILD_DIR)/lib/libgopher-orch.a 2>/dev/null | wc -l) files"; \ - fi; \ - else \ - echo "$(YELLOW)Static library not found$(NC)"; \ - fi - @echo "" - @if [ -f $(BUILD_DIR)/lib/libgopher-orch.so ] || [ -f $(BUILD_DIR)/lib/libgopher-orch.dylib ]; then \ - echo "$(GREEN)Shared library:$(NC)"; \ - LIB_PATH=$$(find $(BUILD_DIR)/lib -name "libgopher-orch.so*" -o -name "libgopher-orch.dylib" | head -1); \ - if [ -n "$$LIB_PATH" ]; then \ - echo " Path: $$LIB_PATH"; \ - echo " Size: $$(du -h $$LIB_PATH | cut -f1)"; \ - if command -v ldd >/dev/null 2>&1; then \ - echo " Dependencies:"; \ - ldd $$LIB_PATH | head -5 | sed 's/^/ /'; \ - elif command -v otool >/dev/null 2>&1; then \ - echo " Dependencies:"; \ - otool -L $$LIB_PATH | head -5 | sed 's/^/ /'; \ - fi; \ - fi; \ - else \ - echo "$(YELLOW)Shared library not found$(NC)"; \ - fi - @echo "" - @if [ -f $(BUILD_DIR)/CMakeCache.txt ]; then \ - echo "$(BLUE)Current configuration:$(NC)"; \ - grep -E "^(BUILD_SHARED_LIBS|BUILD_STATIC_LIBS):BOOL=" $(BUILD_DIR)/CMakeCache.txt | sed 's/^/ /'; \ - fi - -# Show build configuration -.PHONY: info -info: - @echo "$(BLUE)Build Configuration:$(NC)" - @echo " Build directory: $(BUILD_DIR)" - @echo " Build type: $(BUILD_TYPE)" - @echo " Generator: $(GENERATOR)" - @echo " Parallel jobs: $(PARALLEL_JOBS)" - @echo " Build static libs: $(BUILD_STATIC)" - @echo " Build shared libs: $(BUILD_SHARED)" - @echo " CMake options: $(CMAKE_OPTIONS)" - @if [ -f $(BUILD_DIR)/CMakeCache.txt ]; then \ - echo "\n$(BLUE)Current CMake cache:$(NC)"; \ - grep -E "^(CMAKE_BUILD_TYPE|BUILD_SHARED_LIBS|BUILD_STATIC_LIBS|USE_SUBMODULE_GOPHER_MCP)" $(BUILD_DIR)/CMakeCache.txt || true; \ - else \ - echo "\n$(YELLOW)No build directory found. Run 'make configure' first.$(NC)"; \ - fi - -# Help target -.PHONY: help -help: - @echo "$(BLUE)gopher-orch Build System$(NC)" - @echo "" - @echo "$(GREEN)Common targets:$(NC)" - @echo " make - Build both libraries and run tests (default)" - @echo " make build - Build both static and shared libraries" - @echo " make release - Build and test in release mode" - @echo " make test - Run tests" - @echo " make clean - Clean build directory" - @echo " make install - Install the libraries" - @echo " make uninstall - Uninstall the libraries" - @echo "" - @echo "$(GREEN)Library build targets:$(NC)" - @echo " make both - Build both library types (default)" - @echo " make static - Build static library only (with clean)" - @echo " make shared - Build shared library only (with clean)" - @echo " make libs - Build libraries (current config)" - @echo " make lib-info - Show detailed library information" - @echo "" - @echo "$(GREEN)Build modes:$(NC)" - @echo " make debug - Build in debug mode" - @echo " make release - Build in release mode" - @echo " make standalone - Build without gopher-mcp" - @echo "" - @echo "$(GREEN)Test targets:$(NC)" - @echo " make test-verbose - Run tests with verbose output" - @echo " make test-parallel - Run tests in parallel" - @echo " make test-one TEST=name - Run specific test" - @echo "" - @echo "$(GREEN)Component targets:$(NC)" - @echo " make libs - Build only libraries" - @echo " make examples - Build examples" - @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" - @echo "" - @echo "$(GREEN)Code Quality:$(NC)" - @echo " make format - Auto-format all source files" - @echo " make check-format - Check formatting without modifying" - @echo " make format-check - Alias for check-format" - @echo "" - @echo "$(GREEN)Utilities:$(NC)" - @echo " make docs - Generate documentation" - @echo " make info - Show build configuration" - @echo " make distclean - Deep clean including submodules" - @echo "" - @echo "$(GREEN)Variables:$(NC)" - @echo " BUILD_DIR=dir - Set build directory (default: build)" - @echo " BUILD_TYPE=type - Set build type (Debug/Release, default: Debug)" - @echo " BUILD_STATIC=ON/OFF - Build static library (default: ON)" - @echo " BUILD_SHARED=ON/OFF - Build shared library (default: ON)" - @echo " CMAKE_OPTIONS=opts - Additional CMake options" - @echo " PARALLEL_JOBS=n - Number of parallel jobs" - @echo "" - @echo "$(GREEN)Examples:$(NC)" - @echo " make - Build both libraries (default)" - @echo " make BUILD_SHARED=OFF - Build static library only" - @echo " make BUILD_TYPE=Release - Build both libraries in release mode" - @echo " make static - Build only static library" - -.DEFAULT_GOAL := all diff --git a/README.md b/README.md deleted file mode 100644 index 91c0b0ca..00000000 --- a/README.md +++ /dev/null @@ -1,382 +0,0 @@ -# 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/build.sh deleted file mode 100755 index 3aa8a1ca..00000000 --- a/build.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/bin/bash -x - -# Build script for gopher-orch with submodule support - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${BLUE}=== gopher-orch Build Script ===${NC}" - -# Parse arguments -BUILD_TYPE="${BUILD_TYPE:-Debug}" -BUILD_DIR="${BUILD_DIR:-build}" -USE_SUBMODULE=ON -BUILD_TESTS=ON -BUILD_EXAMPLES=ON - -for arg in "$@"; do - case $arg in - --release) - BUILD_TYPE=Release - shift - ;; - --no-submodule) - USE_SUBMODULE=OFF - shift - ;; - --no-tests) - BUILD_TESTS=OFF - shift - ;; - --no-examples) - BUILD_EXAMPLES=OFF - shift - ;; - --standalone) - # Build without gopher-mcp for testing - USE_SUBMODULE=OFF - BUILD_WITHOUT_MCP=ON - shift - ;; - --clean) - echo -e "${YELLOW}Cleaning build directory...${NC}" - rm -rf "$BUILD_DIR" - shift - ;; - --help) - echo "Usage: $0 [options]" - echo "Options:" - echo " --release Build in Release mode (default: Debug)" - echo " --no-submodule Use system gopher-mcp instead of submodule" - echo " --no-tests Don't build tests" - echo " --no-examples Don't build examples" - echo " --standalone Build without gopher-mcp dependency" - echo " --clean Clean build directory before building" - echo " --help Show this help message" - exit 0 - ;; - esac -done - -# Initialize submodule if needed -if [ "$USE_SUBMODULE" = "ON" ] && [ "${BUILD_WITHOUT_MCP:-OFF}" = "OFF" ]; then - if [ ! -f "third_party/gopher-mcp/CMakeLists.txt" ]; then - echo -e "${YELLOW}Initializing gopher-mcp submodule...${NC}" - git submodule update --init --recursive third_party/gopher-mcp - else - echo -e "${GREEN}gopher-mcp submodule already initialized${NC}" - fi -fi - -# Create build directory -mkdir -p "$BUILD_DIR" - -# Configure -echo -e "${BLUE}Configuring with CMake...${NC}" -echo " Build type: $BUILD_TYPE" -echo " Use submodule: $USE_SUBMODULE" -echo " Build tests: $BUILD_TESTS" -echo " Build examples: $BUILD_EXAMPLES" - -CMAKE_ARGS=( - -DCMAKE_BUILD_TYPE="$BUILD_TYPE" - -DUSE_SUBMODULE_GOPHER_MCP="$USE_SUBMODULE" - -DBUILD_TESTS="$BUILD_TESTS" - -DBUILD_EXAMPLES="$BUILD_EXAMPLES" -) - -if [ "${BUILD_WITHOUT_MCP:-OFF}" = "ON" ]; then - CMAKE_ARGS+=(-DBUILD_WITHOUT_GOPHER_MCP=ON) - echo -e "${YELLOW}Building without gopher-mcp dependency (standalone mode)${NC}" -fi - -cmake -B "$BUILD_DIR" -S . "${CMAKE_ARGS[@]}" - -# Build -echo -e "${BLUE}Building...${NC}" -cmake --build "$BUILD_DIR" -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) - -echo -e "${GREEN}Build completed successfully!${NC}" - -# Run tests if built -if [ "$BUILD_TESTS" = "ON" ]; then - echo -e "${BLUE}Running tests...${NC}" - (cd "$BUILD_DIR" && ctest --output-on-failure) || { - echo -e "${RED}Some tests failed${NC}" - exit 1 - } - echo -e "${GREEN}All tests passed!${NC}" -fi - -# Show example usage -if [ "$BUILD_EXAMPLES" = "ON" ] && [ -f "$BUILD_DIR/bin/hello_world_example" ]; then - echo -e "${BLUE}Example built:${NC}" - echo " Run: ./$BUILD_DIR/bin/hello_world_example" -fi - -echo -e "${GREEN}=== Build Complete ===${NC}" diff --git a/cmake/cmake_uninstall.cmake.in b/cmake/cmake_uninstall.cmake.in deleted file mode 100644 index 25ae5708..00000000 --- a/cmake/cmake_uninstall.cmake.in +++ /dev/null @@ -1,49 +0,0 @@ -# cmake_uninstall.cmake.in -# Uninstall script for gopher-orch - -if(NOT EXISTS "@CMAKE_BINARY_DIR@/install_manifest.txt") - message(FATAL_ERROR "Cannot find install manifest: @CMAKE_BINARY_DIR@/install_manifest.txt") -endif() - -file(READ "@CMAKE_BINARY_DIR@/install_manifest.txt" files) -string(REGEX REPLACE "\n" ";" files "${files}") - -foreach(file ${files}) - message(STATUS "Uninstalling $ENV{DESTDIR}${file}") - if(IS_SYMLINK "$ENV{DESTDIR}${file}" OR EXISTS "$ENV{DESTDIR}${file}") - exec_program( - "@CMAKE_COMMAND@" ARGS "-E remove \"$ENV{DESTDIR}${file}\"" - OUTPUT_VARIABLE rm_out - RETURN_VALUE rm_retval - ) - if(NOT "${rm_retval}" STREQUAL 0) - message(FATAL_ERROR "Problem when removing $ENV{DESTDIR}${file}") - endif() - else() - message(STATUS "File $ENV{DESTDIR}${file} does not exist.") - endif() -endforeach() - -# Remove empty directories -set(DIRS_TO_CHECK - "@CMAKE_INSTALL_PREFIX@/lib/cmake/gopher-orch" - "@CMAKE_INSTALL_PREFIX@/include/orch/core" - "@CMAKE_INSTALL_PREFIX@/include/orch" -) - -foreach(dir ${DIRS_TO_CHECK}) - if(EXISTS "$ENV{DESTDIR}${dir}") - file(GLOB dir_contents "$ENV{DESTDIR}${dir}/*") - list(LENGTH dir_contents n_contents) - if(n_contents EQUAL 0) - message(STATUS "Removing empty directory: $ENV{DESTDIR}${dir}") - exec_program( - "@CMAKE_COMMAND@" ARGS "-E remove_directory \"$ENV{DESTDIR}${dir}\"" - OUTPUT_VARIABLE rm_out - RETURN_VALUE rm_retval - ) - endif() - endif() -endforeach() - -message(STATUS "Uninstall complete") diff --git a/cmake/gopher-orch-config.cmake.in b/cmake/gopher-orch-config.cmake.in deleted file mode 100644 index 89457eef..00000000 --- a/cmake/gopher-orch-config.cmake.in +++ /dev/null @@ -1,23 +0,0 @@ -@PACKAGE_INIT@ - -include(CMakeFindDependencyMacro) - -# Find required dependencies -if(@USE_SUBMODULE_GOPHER_MCP@) - # When gopher-orch was built with submodule, users need gopher-mcp - find_dependency(gopher-mcp REQUIRED) -endif() - -# Find threads -find_dependency(Threads REQUIRED) - -# Include the targets file -include("${CMAKE_CURRENT_LIST_DIR}/gopher-orch-targets.cmake") - -# Set variables for compatibility -set(gopher-orch_FOUND TRUE) -set(gopher-orch_INCLUDE_DIRS "@CMAKE_INSTALL_PREFIX@/include") -set(gopher-orch_LIBRARIES gopher-orch) - -# Check required components -check_required_components(gopher-orch) diff --git a/docs/Agent.md b/docs/Agent.md deleted file mode 100644 index 6e85e1f1..00000000 --- a/docs/Agent.md +++ /dev/null @@ -1,497 +0,0 @@ -# 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/docs/AgentRunnable.md b/docs/AgentRunnable.md deleted file mode 100644 index f82e006d..00000000 --- a/docs/AgentRunnable.md +++ /dev/null @@ -1,863 +0,0 @@ -# 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/docs/Composition.md b/docs/Composition.md deleted file mode 100644 index 7a779aa8..00000000 --- a/docs/Composition.md +++ /dev/null @@ -1,258 +0,0 @@ -# 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/docs/FFI.md b/docs/FFI.md deleted file mode 100644 index e2515801..00000000 --- a/docs/FFI.md +++ /dev/null @@ -1,414 +0,0 @@ -# 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/docs/LLMProvider.md b/docs/LLMProvider.md deleted file mode 100644 index 283237a3..00000000 --- a/docs/LLMProvider.md +++ /dev/null @@ -1,331 +0,0 @@ -# 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/docs/Resilience.md b/docs/Resilience.md deleted file mode 100644 index 385076de..00000000 --- a/docs/Resilience.md +++ /dev/null @@ -1,323 +0,0 @@ -# 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/docs/Runnable.md b/docs/Runnable.md deleted file mode 100644 index f5e12f4a..00000000 --- a/docs/Runnable.md +++ /dev/null @@ -1,222 +0,0 @@ -# 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/docs/Server.md b/docs/Server.md deleted file mode 100644 index 84693e24..00000000 --- a/docs/Server.md +++ /dev/null @@ -1,301 +0,0 @@ -# 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/docs/StateGraph.md b/docs/StateGraph.md deleted file mode 100644 index f4c1d14e..00000000 --- a/docs/StateGraph.md +++ /dev/null @@ -1,305 +0,0 @@ -# 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/docs/ToolRegistry.md b/docs/ToolRegistry.md deleted file mode 100644 index 459a7543..00000000 --- a/docs/ToolRegistry.md +++ /dev/null @@ -1,485 +0,0 @@ -# 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/examples/CMakeLists.txt deleted file mode 100644 index 240bb3ee..00000000 --- a/examples/CMakeLists.txt +++ /dev/null @@ -1,7 +0,0 @@ -# gopher-orch examples - -# Hello World example (basic orch functionality) -add_subdirectory(hello_world) - -# MCP Client example (demonstrates gopher-mcp integration) -add_subdirectory(mcp_client) diff --git a/examples/chatbot/README.md b/examples/chatbot/README.md deleted file mode 100644 index 2bb97c05..00000000 --- a/examples/chatbot/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# 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/examples/chatbot/main.cc b/examples/chatbot/main.cc deleted file mode 100644 index 21e32258..00000000 --- a/examples/chatbot/main.cc +++ /dev/null @@ -1,154 +0,0 @@ -// 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/examples/hello_world/CMakeLists.txt deleted file mode 100644 index 236c04f8..00000000 --- a/examples/hello_world/CMakeLists.txt +++ /dev/null @@ -1,15 +0,0 @@ -add_executable(hello_world_example main.cpp) - -target_link_libraries(hello_world_example - gopher-orch - ${GOPHER_MCP_LIBRARIES} - Threads::Threads -) - -target_include_directories(hello_world_example PRIVATE - ${CMAKE_SOURCE_DIR}/include - ${GOPHER_MCP_INCLUDE_DIR} -) - -# Examples are not installed by default -# To install, use: cmake --install . --component examples diff --git a/examples/hello_world/main.cpp b/examples/hello_world/main.cpp deleted file mode 100644 index 5c79dbd8..00000000 --- a/examples/hello_world/main.cpp +++ /dev/null @@ -1,78 +0,0 @@ -#include -#include -#include - -#include "orch/core/hello.h" -#include "orch/core/version.h" - -using namespace gopher::orch::core; - -int main(int argc, char* argv[]) { - std::cout << "gopher-orch version: " << Version::string() << std::endl; - std::cout << "----------------------------------------" << std::endl; - - // Basic usage - { - std::cout << "\n1. Basic Hello usage:" << std::endl; - Hello hello; - std::cout << " " << hello.greet() << std::endl; - - hello.set_name("gopher-orch User"); - std::cout << " " << hello.greet() << std::endl; - } - - // Constructor with parameter - { - std::cout << "\n2. Parameterized constructor:" << std::endl; - Hello hello("Alice"); - std::cout << " " << hello.greet() << std::endl; - } - - // Custom prefix - { - std::cout << "\n3. Custom prefix greetings:" << std::endl; - Hello hello("Bob"); - std::cout << " " << hello.greet_with_prefix("Hi") << std::endl; - std::cout << " " << hello.greet_with_prefix("Welcome") << std::endl; - std::cout << " " << hello.greet_with_prefix("Greetings") << std::endl; - } - - // Builder pattern - { - std::cout << "\n4. Using HelloBuilder:" << std::endl; - HelloBuilder builder; - - auto hello1 = builder.with_name("Charlie").build(); - std::cout << " " << hello1->greet() << std::endl; - - auto hello2 = - builder.with_name("Diana").with_greeting_style("formal").build(); - std::cout << " " << hello2->greet() << std::endl; - } - - // Command line argument - if (argc > 1) { - std::cout << "\n5. Using command line argument:" << std::endl; - Hello hello(argv[1]); - std::cout << " " << hello.greet() << std::endl; - } - - // Multiple instances - { - std::cout << "\n6. Multiple instances:" << std::endl; - std::vector> hellos; - - hellos.push_back(std::make_unique("User1")); - hellos.push_back(std::make_unique("User2")); - hellos.push_back(std::make_unique("User3")); - - for (const auto& hello : hellos) { - std::cout << " " << hello->greet() << std::endl; - } - } - - std::cout << "\n----------------------------------------" << std::endl; - std::cout << "Example completed successfully!" << std::endl; - - return 0; -} diff --git a/examples/mcp_client/CMakeLists.txt b/examples/mcp_client/CMakeLists.txt deleted file mode 100644 index 6437f789..00000000 --- a/examples/mcp_client/CMakeLists.txt +++ /dev/null @@ -1,32 +0,0 @@ -# MCP Client Example -# Demonstrates gopher-mcp integration with gopher-orch -cmake_minimum_required(VERSION 3.10) - -# Define the executable -add_executable(mcp_client_example - mcp_client_example.cc -) - -# Set target properties -set_target_properties(mcp_client_example PROPERTIES - CXX_STANDARD 14 - CXX_STANDARD_REQUIRED ON - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" -) - -# Link against gopher-orch and gopher-mcp libraries -target_link_libraries(mcp_client_example PRIVATE - gopher-orch-static - gopher-mcp - gopher-mcp-event - ${CMAKE_THREAD_LIBS_INIT} -) - -# Include directories -target_include_directories(mcp_client_example PRIVATE - ${CMAKE_SOURCE_DIR}/include - ${GOPHER_MCP_INCLUDE_DIR} -) - -# Examples are not installed by default -# To install, use: cmake --install . --component examples diff --git a/examples/mcp_client/mcp_client_example.cc b/examples/mcp_client/mcp_client_example.cc deleted file mode 100644 index 6d43193e..00000000 --- a/examples/mcp_client/mcp_client_example.cc +++ /dev/null @@ -1,153 +0,0 @@ -/** - * @file mcp_client_example.cc - * @brief Example demonstrating gopher-orch integration with gopher-mcp - * - * This example shows how gopher-orch can extend and use gopher-mcp - * functionality. It demonstrates: - * 1. Using gopher-orch's Hello class - * 2. Using gopher-mcp types and utilities - * 3. Integration between both libraries - */ - -#include -#include -#include - -// gopher-orch includes -#include "orch/core/hello.h" -#include "orch/core/version.h" - -// gopher-mcp includes -#include "mcp/json/json_bridge.h" -#include "mcp/types.h" - -using namespace gopher::orch::core; -using namespace mcp; - -int main(int argc, char* argv[]) { - std::cout << "=== gopher-orch + gopher-mcp Integration Example ===" - << std::endl; - std::cout << std::endl; - - // Show versions - std::cout << "Versions:" << std::endl; - std::cout << " gopher-orch: " << Version::string() << std::endl; - std::cout << std::endl; - - // Demonstrate gopher-orch Hello class - std::cout << "1. gopher-orch Hello class:" << std::endl; - Hello hello("MCP User"); - std::cout << " " << hello.greet() << std::endl; - std::cout << std::endl; - - // Demonstrate gopher-mcp types - std::cout << "2. gopher-mcp types:" << std::endl; - - // Create a Tool definition - Tool calculator_tool; - calculator_tool.name = "calculator"; - calculator_tool.description = mcp::make_optional( - std::string("A simple calculator tool for basic arithmetic")); - - // Create input schema - json::JsonValue schema; - schema["type"] = "object"; - schema["properties"]["operation"]["type"] = "string"; - schema["properties"]["a"]["type"] = "number"; - schema["properties"]["b"]["type"] = "number"; - - auto required_arr = json::JsonValue::array(); - required_arr.push_back("operation"); - required_arr.push_back("a"); - required_arr.push_back("b"); - schema["required"] = required_arr; - - calculator_tool.inputSchema = mcp::make_optional(schema); - - std::cout << " Created Tool: " << calculator_tool.name << std::endl; - if (calculator_tool.description.has_value()) { - std::cout << " Description: " << calculator_tool.description.value() - << std::endl; - } - std::cout << std::endl; - - // Create a Resource definition - Resource sample_resource; - sample_resource.uri = "file:///example/data.json"; - sample_resource.name = "Example Data"; - sample_resource.description = - 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; - std::cout << " Name: " << sample_resource.name << std::endl; - if (sample_resource.mimeType.has_value()) { - std::cout << " MIME Type: " << sample_resource.mimeType.value() - << std::endl; - } - std::cout << std::endl; - - // Create a Prompt definition - Prompt greeting_prompt; - greeting_prompt.name = "greeting"; - greeting_prompt.description = - mcp::make_optional(std::string("A simple greeting prompt")); - - PromptArgument name_arg; - name_arg.name = "name"; - name_arg.description = mcp::make_optional(std::string("The name to greet")); - name_arg.required = true; - - greeting_prompt.arguments = - mcp::make_optional(std::vector{name_arg}); - - std::cout << "4. MCP Prompt:" << std::endl; - std::cout << " Name: " << greeting_prompt.name << std::endl; - if (greeting_prompt.description.has_value()) { - std::cout << " Description: " << greeting_prompt.description.value() - << std::endl; - } - if (greeting_prompt.arguments.has_value()) { - std::cout << " Arguments: " << greeting_prompt.arguments.value().size() - << std::endl; - for (const auto& arg : greeting_prompt.arguments.value()) { - std::cout << " - " << arg.name; - if (arg.required) { - std::cout << " (required)"; - } - std::cout << std::endl; - } - } - std::cout << std::endl; - - // Demonstrate JSON serialization - std::cout << "5. JSON Operations:" << std::endl; - json::JsonValue data; - data["greeting"] = hello.greet(); - data["version"] = Version::string(); - data["tool_count"] = 1; - data["resource_count"] = 1; - - std::cout << " Created JSON object with greeting and version info" - << std::endl; - std::cout << std::endl; - - // Integration example: Using gopher-orch to enhance gopher-mcp - std::cout << "6. Integration Example:" << std::endl; - HelloBuilder builder; - auto orchestrator = builder.with_name("MCP Orchestrator").build(); - std::cout << " " << orchestrator->greet() << std::endl; - std::cout - << " This demonstrates gopher-orch extending gopher-mcp capabilities" - << std::endl; - std::cout << std::endl; - - std::cout << "=== Example Complete ===" << std::endl; - std::cout - << "The gopher-orch library successfully integrates with gopher-mcp!" - << std::endl; - - return 0; -} diff --git a/examples/multi_agent/README.md b/examples/multi_agent/README.md deleted file mode 100644 index 7c6d8193..00000000 --- a/examples/multi_agent/README.md +++ /dev/null @@ -1,159 +0,0 @@ -# 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/examples/multi_agent/main.cc b/examples/multi_agent/main.cc deleted file mode 100644 index 1c3f1182..00000000 --- a/examples/multi_agent/main.cc +++ /dev/null @@ -1,257 +0,0 @@ -// 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/examples/resilient_api/README.md b/examples/resilient_api/README.md deleted file mode 100644 index cc7b4fc0..00000000 --- a/examples/resilient_api/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# 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/examples/resilient_api/main.cc b/examples/resilient_api/main.cc deleted file mode 100644 index 9a9c1630..00000000 --- a/examples/resilient_api/main.cc +++ /dev/null @@ -1,277 +0,0 @@ -// 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/examples/simple_agent/README.md b/examples/simple_agent/README.md deleted file mode 100644 index eef8cd0c..00000000 --- a/examples/simple_agent/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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/examples/simple_agent/main.cc b/examples/simple_agent/main.cc deleted file mode 100644 index 5fbbc453..00000000 --- a/examples/simple_agent/main.cc +++ /dev/null @@ -1,165 +0,0 @@ -// 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/examples/workflow/README.md b/examples/workflow/README.md deleted file mode 100644 index b59c7e17..00000000 --- a/examples/workflow/README.md +++ /dev/null @@ -1,151 +0,0 @@ -# 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/examples/workflow/main.cc b/examples/workflow/main.cc deleted file mode 100644 index 2531f119..00000000 --- a/examples/workflow/main.cc +++ /dev/null @@ -1,230 +0,0 @@ -// 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/include/gopher/orch/agent/agent.h b/include/gopher/orch/agent/agent.h deleted file mode 100644 index fdb9261d..00000000 --- a/include/gopher/orch/agent/agent.h +++ /dev/null @@ -1,170 +0,0 @@ -#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 declaration -class Agent; -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); - - private: - explicit ReActAgent(LLMProviderPtr provider, - ToolRegistryPtr tools, - const AgentConfig& config); - - // 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/include/gopher/orch/agent/agent_module.h b/include/gopher/orch/agent/agent_module.h deleted file mode 100644 index 679ba753..00000000 --- a/include/gopher/orch/agent/agent_module.h +++ /dev/null @@ -1,67 +0,0 @@ -#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" - -// 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/include/gopher/orch/agent/agent_runnable.h b/include/gopher/orch/agent/agent_runnable.h deleted file mode 100644 index fffa313b..00000000 --- a/include/gopher/orch/agent/agent_runnable.h +++ /dev/null @@ -1,216 +0,0 @@ -#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/include/gopher/orch/agent/agent_types.h b/include/gopher/orch/agent/agent_types.h deleted file mode 100644 index 9a63553e..00000000 --- a/include/gopher/orch/agent/agent_types.h +++ /dev/null @@ -1,484 +0,0 @@ -#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/include/gopher/orch/agent/config_loader.h b/include/gopher/orch/agent/config_loader.h deleted file mode 100644 index ff19ca86..00000000 --- a/include/gopher/orch/agent/config_loader.h +++ /dev/null @@ -1,438 +0,0 @@ -#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); - 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) { - 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: { - if (json.contains("stdio")) { - const auto& stdio = json["stdio"]; - MCPServerDefinition::StdioConfig cfg; - cfg.command = substituteEnvVars( - stdio.contains("command") ? stdio["command"].getString() : ""); - - if (stdio.contains("args") && stdio["args"].isArray()) { - const auto& args = stdio["args"]; - for (size_t i = 0; i < args.size(); ++i) { - cfg.args.push_back(substituteEnvVars(args[i].getString())); - } - } - - if (stdio.contains("env") && stdio["env"].isObject()) { - for (auto it = stdio["env"].begin(); it != stdio["env"].end(); ++it) { - auto kv = *it; - cfg.env[kv.first] = substituteEnvVars(kv.second.getString()); - } - } - - cfg.working_directory = stdio.contains("working_directory") - ? stdio["working_directory"].getString() - : ""; - def.stdio_config = std::move(cfg); - } - break; - } - - case MCPServerDefinition::TransportType::HTTP_SSE: { - if (json.contains("http_sse")) { - const auto& sse = json["http_sse"]; - MCPServerDefinition::HttpSseConfig cfg; - cfg.url = substituteEnvVars(sse.contains("url") ? sse["url"].getString() - : ""); - cfg.verify_ssl = - sse.contains("verify_ssl") ? sse["verify_ssl"].getBool() : true; - - if (sse.contains("headers") && sse["headers"].isObject()) { - for (auto it = sse["headers"].begin(); it != sse["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: { - if (json.contains("websocket")) { - const auto& ws = json["websocket"]; - MCPServerDefinition::WebSocketConfig cfg; - cfg.url = - substituteEnvVars(ws.contains("url") ? ws["url"].getString() : ""); - cfg.verify_ssl = - ws.contains("verify_ssl") ? ws["verify_ssl"].getBool() : true; - - if (ws.contains("headers") && ws["headers"].isObject()) { - for (auto it = ws["headers"].begin(); it != ws["headers"].end(); - ++it) { - auto kv = *it; - cfg.headers[kv.first] = substituteEnvVars(kv.second.getString()); - } - } - - def.websocket_config = std::move(cfg); - } - break; - } - } - - // Parse timeouts - if (json.contains("connect_timeout_ms")) { - def.connect_timeout = - std::chrono::milliseconds(json["connect_timeout_ms"].getInt()); - } - if (json.contains("request_timeout_ms")) { - def.request_timeout = - std::chrono::milliseconds(json["request_timeout_ms"].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; - - config.name = - json.contains("name") ? json["name"].getString() : "tool-registry"; - config.base_url = substituteEnvVars( - json.contains("base_url") ? json["base_url"].getString() : ""); - - // Parse default headers - if (json.contains("default_headers") && json["default_headers"].isObject()) { - for (auto it = json["default_headers"].begin(); - it != json["default_headers"].end(); ++it) { - auto kv = *it; - config.default_headers[kv.first] = - substituteEnvVars(kv.second.getString()); - } - } - - // Parse auth presets - if (json.contains("auth_presets") && json["auth_presets"].isObject()) { - for (auto it = json["auth_presets"].begin(); - it != json["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 - if (json.contains("mcp_servers") && json["mcp_servers"].isArray()) { - const auto& servers = json["mcp_servers"]; - for (size_t i = 0; i < servers.size(); ++i) { - auto server_result = parseMCPServerDefinition(servers[i]); - if (mcp::holds_alternative(server_result)) { - config.mcp_servers.push_back( - std::move(mcp::get(server_result))); - } - } - } - - // Parse tools - if (json.contains("tools") && json["tools"].isArray()) { - const auto& tools = json["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/include/gopher/orch/agent/rest_tool_adapter.h b/include/gopher/orch/agent/rest_tool_adapter.h deleted file mode 100644 index aaf45e45..00000000 --- a/include/gopher/orch/agent/rest_tool_adapter.h +++ /dev/null @@ -1,294 +0,0 @@ -#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/include/gopher/orch/agent/tool_definition.h b/include/gopher/orch/agent/tool_definition.h deleted file mode 100644 index 49e1b6c7..00000000 --- a/include/gopher/orch/agent/tool_definition.h +++ /dev/null @@ -1,354 +0,0 @@ -#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/include/gopher/orch/agent/tool_executor.h b/include/gopher/orch/agent/tool_executor.h deleted file mode 100644 index 843c7fd4..00000000 --- a/include/gopher/orch/agent/tool_executor.h +++ /dev/null @@ -1,144 +0,0 @@ -#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 { - // Execute on remote server using original name - 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/include/gopher/orch/agent/tool_registry.h b/include/gopher/orch/agent/tool_registry.h deleted file mode 100644 index 998e9bdb..00000000 --- a/include/gopher/orch/agent/tool_registry.h +++ /dev/null @@ -1,427 +0,0 @@ -#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 "gopher/orch/core/types.h" -#include "gopher/orch/llm/llm_types.h" -#include "gopher/orch/server/server.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(); } - - // ═══════════════════════════════════════════════════════════════════════════ - // 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); - } - - // List and register tools - server->listTools( - dispatcher, [this, server](Result> result) { - if (!mcp::holds_alternative>(result)) - return; - - std::lock_guard lock(mutex_); - for (const auto& info : - mcp::get>(result)) { - 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); - - 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; - specs.reserve(tools_.size()); - - for (const auto& pair : tools_) { - specs.push_back(pair.second.spec); - } - - 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 - size_t toolCount() const { - std::lock_guard lock(mutex_); - return tools_.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 -}; - -// Convenience function to create registry -inline ToolRegistryPtr makeToolRegistry() { return ToolRegistry::create(); } - -} // namespace agent -} // namespace orch -} // namespace gopher diff --git a/include/gopher/orch/agent/tool_runnable.h b/include/gopher/orch/agent/tool_runnable.h deleted file mode 100644 index d826bf3e..00000000 --- a/include/gopher/orch/agent/tool_runnable.h +++ /dev/null @@ -1,128 +0,0 @@ -#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/include/gopher/orch/callback/callback_handler.h b/include/gopher/orch/callback/callback_handler.h deleted file mode 100644 index eecfc470..00000000 --- a/include/gopher/orch/callback/callback_handler.h +++ /dev/null @@ -1,292 +0,0 @@ -#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/include/gopher/orch/callback/callback_manager.h b/include/gopher/orch/callback/callback_manager.h deleted file mode 100644 index 40b64ad2..00000000 --- a/include/gopher/orch/callback/callback_manager.h +++ /dev/null @@ -1,483 +0,0 @@ -#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/include/gopher/orch/composition/parallel.h b/include/gopher/orch/composition/parallel.h deleted file mode 100644 index fe3fb1e2..00000000 --- a/include/gopher/orch/composition/parallel.h +++ /dev/null @@ -1,183 +0,0 @@ -#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/include/gopher/orch/composition/router.h b/include/gopher/orch/composition/router.h deleted file mode 100644 index 31dc45f6..00000000 --- a/include/gopher/orch/composition/router.h +++ /dev/null @@ -1,147 +0,0 @@ -#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/include/gopher/orch/composition/sequence.h b/include/gopher/orch/composition/sequence.h deleted file mode 100644 index 3327b7a8..00000000 --- a/include/gopher/orch/composition/sequence.h +++ /dev/null @@ -1,207 +0,0 @@ -#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/include/gopher/orch/core/config.h b/include/gopher/orch/core/config.h deleted file mode 100644 index 22f88aca..00000000 --- a/include/gopher/orch/core/config.h +++ /dev/null @@ -1,146 +0,0 @@ -#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()) { - // Explicit namespace to avoid ambiguity with std::make_optional in C++17 - 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/include/gopher/orch/core/lambda.h b/include/gopher/orch/core/lambda.h deleted file mode 100644 index 5cda34c2..00000000 --- a/include/gopher/orch/core/lambda.h +++ /dev/null @@ -1,145 +0,0 @@ -#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/include/gopher/orch/core/runnable.h b/include/gopher/orch/core/runnable.h deleted file mode 100644 index 8040caa4..00000000 --- a/include/gopher/orch/core/runnable.h +++ /dev/null @@ -1,115 +0,0 @@ -#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/include/gopher/orch/core/types.h b/include/gopher/orch/core/types.h deleted file mode 100644 index 99f17bf1..00000000 --- a/include/gopher/orch/core/types.h +++ /dev/null @@ -1,121 +0,0 @@ -#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/include/gopher/orch/ffi/orch_ffi.h b/include/gopher/orch/ffi/orch_ffi.h deleted file mode 100644 index d2d02412..00000000 --- a/include/gopher/orch/ffi/orch_ffi.h +++ /dev/null @@ -1,1341 +0,0 @@ -/** - * @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; - -#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/include/gopher/orch/ffi/orch_ffi_bridge.h b/include/gopher/orch/ffi/orch_ffi_bridge.h deleted file mode 100644 index b30fe06f..00000000 --- a/include/gopher/orch/ffi/orch_ffi_bridge.h +++ /dev/null @@ -1,854 +0,0 @@ -/** - * @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/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; -}; - -/** - * 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/include/gopher/orch/ffi/orch_ffi_raii.h b/include/gopher/orch/ffi/orch_ffi_raii.h deleted file mode 100644 index e598cbc1..00000000 --- a/include/gopher/orch/ffi/orch_ffi_raii.h +++ /dev/null @@ -1,554 +0,0 @@ -/** - * @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/include/gopher/orch/ffi/orch_ffi_types.h b/include/gopher/orch/ffi/orch_ffi_types.h deleted file mode 100644 index ef6b2b41..00000000 --- a/include/gopher/orch/ffi/orch_ffi_types.h +++ /dev/null @@ -1,557 +0,0 @@ -/** - * @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; - -/** 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_JSON = 4, - GOPHER_ORCH_TYPE_CONFIG = 5, - GOPHER_ORCH_TYPE_CALLBACK_MANAGER = 6, - GOPHER_ORCH_TYPE_APPROVAL_HANDLER = 7, - GOPHER_ORCH_TYPE_SEQUENCE = 8, - GOPHER_ORCH_TYPE_PARALLEL = 9, - GOPHER_ORCH_TYPE_ROUTER = 10, - GOPHER_ORCH_TYPE_FSM = 11, - GOPHER_ORCH_TYPE_GRAPH = 12, - GOPHER_ORCH_TYPE_COMPILED_GRAPH = 13, - GOPHER_ORCH_TYPE_CANCEL_TOKEN = 14, - GOPHER_ORCH_TYPE_ITERATOR = 15, - GOPHER_ORCH_TYPE_GUARD = 16, - GOPHER_ORCH_TYPE_TRANSACTION = 17, -} 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/include/gopher/orch/fsm/state_machine.h b/include/gopher/orch/fsm/state_machine.h deleted file mode 100644 index 09bff002..00000000 --- a/include/gopher/orch/fsm/state_machine.h +++ /dev/null @@ -1,335 +0,0 @@ -#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/include/gopher/orch/graph/compiled_graph.h b/include/gopher/orch/graph/compiled_graph.h deleted file mode 100644 index 546377b7..00000000 --- a/include/gopher/orch/graph/compiled_graph.h +++ /dev/null @@ -1,190 +0,0 @@ -#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/include/gopher/orch/graph/graph_node.h b/include/gopher/orch/graph/graph_node.h deleted file mode 100644 index 83851f9b..00000000 --- a/include/gopher/orch/graph/graph_node.h +++ /dev/null @@ -1,56 +0,0 @@ -#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/include/gopher/orch/graph/graph_state.h b/include/gopher/orch/graph/graph_state.h deleted file mode 100644 index edbfdecd..00000000 --- a/include/gopher/orch/graph/graph_state.h +++ /dev/null @@ -1,277 +0,0 @@ -#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/include/gopher/orch/graph/state_graph.h b/include/gopher/orch/graph/state_graph.h deleted file mode 100644 index 2f4d4e67..00000000 --- a/include/gopher/orch/graph/state_graph.h +++ /dev/null @@ -1,187 +0,0 @@ -#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/include/gopher/orch/human/approval.h b/include/gopher/orch/human/approval.h deleted file mode 100644 index 48bfe102..00000000 --- a/include/gopher/orch/human/approval.h +++ /dev/null @@ -1,464 +0,0 @@ -#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/include/gopher/orch/llm/anthropic_provider.h b/include/gopher/orch/llm/anthropic_provider.h deleted file mode 100644 index c538bead..00000000 --- a/include/gopher/orch/llm/anthropic_provider.h +++ /dev/null @@ -1,139 +0,0 @@ -#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/include/gopher/orch/llm/llm.h b/include/gopher/orch/llm/llm.h deleted file mode 100644 index 10de6dd2..00000000 --- a/include/gopher/orch/llm/llm.h +++ /dev/null @@ -1,55 +0,0 @@ -#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/include/gopher/orch/llm/llm_provider.h b/include/gopher/orch/llm/llm_provider.h deleted file mode 100644 index d322c57a..00000000 --- a/include/gopher/orch/llm/llm_provider.h +++ /dev/null @@ -1,191 +0,0 @@ -#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/include/gopher/orch/llm/llm_runnable.h b/include/gopher/orch/llm/llm_runnable.h deleted file mode 100644 index b7e8e82a..00000000 --- a/include/gopher/orch/llm/llm_runnable.h +++ /dev/null @@ -1,120 +0,0 @@ -#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/include/gopher/orch/llm/llm_types.h b/include/gopher/orch/llm/llm_types.h deleted file mode 100644 index 535eec6b..00000000 --- a/include/gopher/orch/llm/llm_types.h +++ /dev/null @@ -1,279 +0,0 @@ -#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/include/gopher/orch/llm/openai_provider.h b/include/gopher/orch/llm/openai_provider.h deleted file mode 100644 index 70ef3449..00000000 --- a/include/gopher/orch/llm/openai_provider.h +++ /dev/null @@ -1,143 +0,0 @@ -#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/include/gopher/orch/orch.h b/include/gopher/orch/orch.h deleted file mode 100644 index 45deb66d..00000000 --- a/include/gopher/orch/orch.h +++ /dev/null @@ -1,263 +0,0 @@ -#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/include/gopher/orch/resilience/circuit_breaker.h b/include/gopher/orch/resilience/circuit_breaker.h deleted file mode 100644 index 9b9784b5..00000000 --- a/include/gopher/orch/resilience/circuit_breaker.h +++ /dev/null @@ -1,250 +0,0 @@ -#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/include/gopher/orch/resilience/fallback.h b/include/gopher/orch/resilience/fallback.h deleted file mode 100644 index 301587f9..00000000 --- a/include/gopher/orch/resilience/fallback.h +++ /dev/null @@ -1,155 +0,0 @@ -#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/include/gopher/orch/resilience/retry.h b/include/gopher/orch/resilience/retry.h deleted file mode 100644 index 919333d6..00000000 --- a/include/gopher/orch/resilience/retry.h +++ /dev/null @@ -1,208 +0,0 @@ -#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/include/gopher/orch/resilience/timeout.h b/include/gopher/orch/resilience/timeout.h deleted file mode 100644 index bbe7e8cf..00000000 --- a/include/gopher/orch/resilience/timeout.h +++ /dev/null @@ -1,130 +0,0 @@ -#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/include/gopher/orch/server/mcp_server.h b/include/gopher/orch/server/mcp_server.h deleted file mode 100644 index 36c1c8b4..00000000 --- a/include/gopher/orch/server/mcp_server.h +++ /dev/null @@ -1,200 +0,0 @@ -#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); - - // 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/include/gopher/orch/server/mock_server.h b/include/gopher/orch/server/mock_server.h deleted file mode 100644 index abbb2c5c..00000000 --- a/include/gopher/orch/server/mock_server.h +++ /dev/null @@ -1,274 +0,0 @@ -#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/include/gopher/orch/server/rest_server.h b/include/gopher/orch/server/rest_server.h deleted file mode 100644 index 381c5b7f..00000000 --- a/include/gopher/orch/server/rest_server.h +++ /dev/null @@ -1,325 +0,0 @@ -#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); -} - -} // namespace server -} // namespace orch -} // namespace gopher diff --git a/include/gopher/orch/server/server.h b/include/gopher/orch/server/server.h deleted file mode 100644 index 498fa9ca..00000000 --- a/include/gopher/orch/server/server.h +++ /dev/null @@ -1,142 +0,0 @@ -#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/include/gopher/orch/server/server_composite.h b/include/gopher/orch/server/server_composite.h deleted file mode 100644 index baf4ac28..00000000 --- a/include/gopher/orch/server/server_composite.h +++ /dev/null @@ -1,412 +0,0 @@ -#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/include/orch/core/hello.h deleted file mode 100644 index 48c6cdc7..00000000 --- a/include/orch/core/hello.h +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once - -#include -#include - -namespace gopher { -namespace orch { -namespace core { - -// Hello class demonstrates basic gopher-orch functionality -// This is a simple example to verify the build system works correctly -class Hello { - public: - Hello(); - explicit Hello(const std::string& name); - ~Hello(); - - std::string greet() const; - std::string greet_with_prefix(const std::string& prefix) const; - - void set_name(const std::string& name); - const std::string& get_name() const; - - static std::string get_version(); - - private: - class Impl; - std::unique_ptr impl_; -}; - -// Builder pattern for Hello class construction -class HelloBuilder { - public: - HelloBuilder& with_name(const std::string& name); - HelloBuilder& with_greeting_style(const std::string& style); - - std::unique_ptr build() const; - - private: - std::string name_ = "World"; - std::string style_ = "default"; -}; - -} // namespace core -} // namespace orch -} // namespace gopher diff --git a/include/orch/core/version.h b/include/orch/core/version.h deleted file mode 100644 index d86166e9..00000000 --- a/include/orch/core/version.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#define GOPHER_ORCH_VERSION_MAJOR 0 -#define GOPHER_ORCH_VERSION_MINOR 1 -#define GOPHER_ORCH_VERSION_PATCH 0 - -#define GOPHER_ORCH_VERSION_STRING "0.1.0" - -namespace gopher { -namespace orch { -namespace core { - -struct Version { - static constexpr int major() { return GOPHER_ORCH_VERSION_MAJOR; } - static constexpr int minor() { return GOPHER_ORCH_VERSION_MINOR; } - static constexpr int patch() { return GOPHER_ORCH_VERSION_PATCH; } - static const char* string() { return GOPHER_ORCH_VERSION_STRING; } -}; - -} // namespace core -} // namespace orch -} // namespace gopher diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt deleted file mode 100644 index 153c08a4..00000000 --- a/src/CMakeLists.txt +++ /dev/null @@ -1,187 +0,0 @@ -# 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 - ) -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/config_loader.cc - gopher/orch/agent/tool_registry.cc - gopher/orch/agent/tool_runnable.cc - gopher/orch/agent/agent_runnable.cc - ) -endif() - -# Combine all sources -set(GOPHER_ORCH_SOURCES - ${ORCH_CORE_SOURCES} - ${ORCH_MCP_SOURCES} - ${ORCH_LLM_SOURCES} - ${ORCH_AGENT_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} - 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} - 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() diff --git a/src/gopher/orch/agent/agent.cc b/src/gopher/orch/agent/agent.cc deleted file mode 100644 index 472d8d81..00000000 --- a/src/gopher/orch/agent/agent.cc +++ /dev/null @@ -1,430 +0,0 @@ -// ReActAgent Implementation - -#include "gopher/orch/agent/agent.h" - -#include -#include - -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(); } - -// ═══════════════════════════════════════════════════════════════════════════ -// 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; -} - -} // namespace agent -} // namespace orch -} // namespace gopher diff --git a/src/gopher/orch/agent/agent_runnable.cc b/src/gopher/orch/agent/agent_runnable.cc deleted file mode 100644 index c022d0dc..00000000 --- a/src/gopher/orch/agent/agent_runnable.cc +++ /dev/null @@ -1,499 +0,0 @@ -// 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/src/gopher/orch/agent/config_loader.cc b/src/gopher/orch/agent/config_loader.cc deleted file mode 100644 index b0ef2209..00000000 --- a/src/gopher/orch/agent/config_loader.cc +++ /dev/null @@ -1,75 +0,0 @@ -// 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/src/gopher/orch/agent/tool_registry.cc b/src/gopher/orch/agent/tool_registry.cc deleted file mode 100644 index 3244beb9..00000000 --- a/src/gopher/orch/agent/tool_registry.cc +++ /dev/null @@ -1,322 +0,0 @@ -// ToolRegistry Config Loading Implementation - -#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/src/gopher/orch/agent/tool_runnable.cc b/src/gopher/orch/agent/tool_runnable.cc deleted file mode 100644 index 851825ab..00000000 --- a/src/gopher/orch/agent/tool_runnable.cc +++ /dev/null @@ -1,213 +0,0 @@ -// 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/src/gopher/orch/llm/anthropic_provider.cc b/src/gopher/orch/llm/anthropic_provider.cc deleted file mode 100644 index a33e38b2..00000000 --- a/src/gopher/orch/llm/anthropic_provider.cc +++ /dev/null @@ -1,420 +0,0 @@ -// Anthropic Provider Implementation - -#include "gopher/orch/llm/anthropic_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 AnthropicProvider::Impl { - public: - AnthropicConfig config; - HttpClientPtr http_client; - mutable std::mutex mutex; - - explicit Impl(const AnthropicConfig& cfg) : config(cfg) { - http_client = std::make_shared(); - } - - 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 -// ═══════════════════════════════════════════════════════════════════════════ - -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) { - return Ptr(new AnthropicProvider(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; - json["input_schema"] = tool.parameters; - 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/src/gopher/orch/llm/llm_factory.cc b/src/gopher/orch/llm/llm_factory.cc deleted file mode 100644 index 2eee8c39..00000000 --- a/src/gopher/orch/llm/llm_factory.cc +++ /dev/null @@ -1,60 +0,0 @@ -// 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/src/gopher/orch/llm/llm_runnable.cc b/src/gopher/orch/llm/llm_runnable.cc deleted file mode 100644 index cfdcf52e..00000000 --- a/src/gopher/orch/llm/llm_runnable.cc +++ /dev/null @@ -1,248 +0,0 @@ -// 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/src/gopher/orch/llm/openai_provider.cc b/src/gopher/orch/llm/openai_provider.cc deleted file mode 100644 index c715eb2b..00000000 --- a/src/gopher/orch/llm/openai_provider.cc +++ /dev/null @@ -1,412 +0,0 @@ -// 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) { - // Create HTTP client for API calls - http_client = std::make_shared(); - } - - 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/src/gopher/orch/server/mcp_server.cc b/src/gopher/orch/server/mcp_server.cc deleted file mode 100644 index a4d140ae..00000000 --- a/src/gopher/orch/server/mcp_server.cc +++ /dev/null @@ -1,477 +0,0 @@ -// 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 - -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; - } - - // Initialize protocol - // Wrap future in shared_ptr to make lambda copyable for std::function - // Note: MCP client returns std::future not std::future> - auto init_future_ptr = std::make_shared>( - client_->initializeProtocol()); - - // Capture self as shared_ptr - // MCPServer inherits from Server which inherits from - // enable_shared_from_this - MCPServer* this_ptr = this; - auto self = std::shared_ptr(std::static_pointer_cast( - this_ptr->Server::shared_from_this())); - - // We need to wait for the future in a non-blocking way - // Post to dispatcher and handle result - dispatcher.post([self, callback, init_future_ptr, &dispatcher]() { - try { - // Wait for and get the result - future throws on error - mcp::InitializeResult init_result = init_future_ptr->get(); - self->onInitialized(dispatcher, init_result, callback); - } catch (const std::exception& e) { - self->state_ = ConnectionState::FAILED; - callback(makeOrchError( - OrchError::CONNECTION_FAILED, - std::string("Failed to initialize MCP protocol: ") + e.what())); - } - }); -} - -// Handle protocol initialization complete -void MCPServer::onInitialized( - Dispatcher& dispatcher, - const mcp::InitializeResult& init_result, - std::function)> callback) { - // 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()); - - dispatcher.post([self, callback, tools_future_ptr]() { - 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)); - } - }); -} - -// 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(); - // 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()) { - // Create Metadata from JsonValue - mcp_args = mcp::Metadata(); - // The arguments need to be copied to Metadata - // For now, we'll pass an empty object; proper conversion needed - } - - auto self = std::static_pointer_cast(Server::shared_from_this()); - auto tool_future_ptr = std::make_shared>( - client_->callTool(name, mcp_args)); - - dispatcher.post([self, callback, tool_future_ptr]() { - try { - mcp::CallToolResult result = tool_future_ptr->get(); - if (result.isError) { - // Tool returned an error - JsonValue error_content = contentToJson(result.content); - callback(makeOrchError(OrchError::INTERNAL_ERROR, - error_content.toString())); - } else { - // Success - convert content to JsonValue - JsonValue json_result = contentToJson(result.content); - callback(makeSuccess(json_result)); - } - } catch (const std::exception& e) { - callback(makeOrchError(OrchError::INTERNAL_ERROR, e.what())); - } - }); -} - -} // namespace server -} // namespace orch -} // namespace gopher diff --git a/src/gopher/orch/server/rest_server.cc b/src/gopher/orch/server/rest_server.cc deleted file mode 100644 index bbb560f5..00000000 --- a/src/gopher/orch/server/rest_server.cc +++ /dev/null @@ -1,418 +0,0 @@ -// 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 provides a stub implementation. -// For actual HTTP requests, inject a custom HttpClient implementation -// that uses a proper HTTP library (e.g., libcurl, boost::beast). -// -// The stub returns an error indicating that no HTTP backend is configured. -// This is by design - the RESTServer is meant to be used with a custom -// HttpClient for production use, or with MockHttpClient for testing. -class DefaultHttpClient::Impl { - public: - Impl() = default; - - void request(HttpMethod method, - const std::string& url, - const std::map& headers, - const std::string& body, - Dispatcher& dispatcher, - HttpClient::ResponseCallback callback) { - (void)method; - (void)url; - (void)headers; - (void)body; - - // Return an error indicating no HTTP backend is configured - // In production, inject a custom HttpClient implementation - dispatcher.post([callback]() { - callback(Result( - Error(OrchError::INTERNAL_ERROR, - "DefaultHttpClient: No HTTP backend configured. " - "Please inject a custom HttpClient implementation."))); - }); - } -}; - -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.cc b/src/orch/hello.cc deleted file mode 100644 index 9b05263c..00000000 --- a/src/orch/hello.cc +++ /dev/null @@ -1,59 +0,0 @@ -#include "orch/core/hello.h" - -#include - -#include "orch/core/version.h" - -namespace gopher { -namespace orch { -namespace core { - -class Hello::Impl { - public: - Impl() : name_("World") {} - explicit Impl(const std::string& name) : name_(name) {} - - std::string name_; -}; - -Hello::Hello() : impl_(std::make_unique()) {} - -Hello::Hello(const std::string& name) : impl_(std::make_unique(name)) {} - -Hello::~Hello() = default; - -std::string Hello::greet() const { - std::ostringstream oss; - oss << "Hello, " << impl_->name_ << "!"; - return oss.str(); -} - -std::string Hello::greet_with_prefix(const std::string& prefix) const { - std::ostringstream oss; - oss << prefix << " " << impl_->name_ << "!"; - return oss.str(); -} - -void Hello::set_name(const std::string& name) { impl_->name_ = name; } - -const std::string& Hello::get_name() const { return impl_->name_; } - -std::string Hello::get_version() { return Version::string(); } - -HelloBuilder& HelloBuilder::with_name(const std::string& name) { - name_ = name; - return *this; -} - -HelloBuilder& HelloBuilder::with_greeting_style(const std::string& style) { - style_ = style; - return *this; -} - -std::unique_ptr HelloBuilder::build() const { - return std::make_unique(name_); -} - -} // namespace core -} // namespace orch -} // namespace gopher diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt deleted file mode 100644 index f3567537..00000000 --- a/tests/CMakeLists.txt +++ /dev/null @@ -1,143 +0,0 @@ -# gopher-orch tests - -# Test utilities and helpers -set(TEST_UTIL_SOURCES - # test_utils.cpp -) - -# Orch-specific core tests -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 -) - -# 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 -) - -# 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}) - # Use static library for tests to avoid duplicate symbol issues - if(TARGET gopher-orch-static) - set(GOPHER_ORCH_TEST_LIB gopher-orch-static) - else() - set(GOPHER_ORCH_TEST_LIB gopher-orch) - endif() - target_link_libraries(${test_name} - ${GOPHER_ORCH_TEST_LIB} - ${GOPHER_MCP_LIBRARIES} - GTest::gtest - GTest::gtest_main - GTest::gmock - Threads::Threads - ) - 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} - ) - - # Add test to CTest - gtest_discover_tests(${test_name} - WORKING_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY} - PROPERTIES LABELS ${ARGN} - ) -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} -) - -# Use static library for tests to avoid duplicate symbol issues -if(TARGET gopher-orch-static) - set(GOPHER_ORCH_TEST_LIB gopher-orch-static) -else() - set(GOPHER_ORCH_TEST_LIB gopher-orch) -endif() - -target_link_libraries(gopher-orch-tests - ${GOPHER_ORCH_TEST_LIB} - ${GOPHER_MCP_LIBRARIES} - GTest::gtest - GTest::gtest_main - GTest::gmock - Threads::Threads -) - -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} -) - -# Custom test targets -add_custom_target(test-verbose - COMMAND ${CMAKE_CTEST_COMMAND} -V - WORKING_DIRECTORY ${CMAKE_BINARY_DIR} -) - -add_custom_target(test-parallel - COMMAND ${CMAKE_CTEST_COMMAND} -j${CMAKE_BUILD_PARALLEL_LEVEL} - WORKING_DIRECTORY ${CMAKE_BINARY_DIR} -) diff --git a/tests/gopher/orch/FFI/ffi_builder_test.cc b/tests/gopher/orch/FFI/ffi_builder_test.cc deleted file mode 100644 index 2ce134af..00000000 --- a/tests/gopher/orch/FFI/ffi_builder_test.cc +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @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/tests/gopher/orch/FFI/ffi_core_test.cc b/tests/gopher/orch/FFI/ffi_core_test.cc deleted file mode 100644 index ad738d6e..00000000 --- a/tests/gopher/orch/FFI/ffi_core_test.cc +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @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/tests/gopher/orch/FFI/ffi_error_test.cc b/tests/gopher/orch/FFI/ffi_error_test.cc deleted file mode 100644 index 63e6efb2..00000000 --- a/tests/gopher/orch/FFI/ffi_error_test.cc +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @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/tests/gopher/orch/FFI/ffi_handle_test.cc b/tests/gopher/orch/FFI/ffi_handle_test.cc deleted file mode 100644 index 442de2ff..00000000 --- a/tests/gopher/orch/FFI/ffi_handle_test.cc +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @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/tests/gopher/orch/FFI/ffi_json_test.cc b/tests/gopher/orch/FFI/ffi_json_test.cc deleted file mode 100644 index b619e0f9..00000000 --- a/tests/gopher/orch/FFI/ffi_json_test.cc +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @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/tests/gopher/orch/FFI/ffi_lambda_test.cc b/tests/gopher/orch/FFI/ffi_lambda_test.cc deleted file mode 100644 index 4ac86d8f..00000000 --- a/tests/gopher/orch/FFI/ffi_lambda_test.cc +++ /dev/null @@ -1,112 +0,0 @@ -/** - * @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/tests/gopher/orch/FFI/ffi_raii_test.cc b/tests/gopher/orch/FFI/ffi_raii_test.cc deleted file mode 100644 index fadcaf32..00000000 --- a/tests/gopher/orch/FFI/ffi_raii_test.cc +++ /dev/null @@ -1,236 +0,0 @@ -/** - * @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/tests/gopher/orch/FFI/ffi_types_test.cc b/tests/gopher/orch/FFI/ffi_types_test.cc deleted file mode 100644 index ec87cfc0..00000000 --- a/tests/gopher/orch/FFI/ffi_types_test.cc +++ /dev/null @@ -1,125 +0,0 @@ -/** - * @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/tests/gopher/orch/agent_runnable_test.cc b/tests/gopher/orch/agent_runnable_test.cc deleted file mode 100644 index 8819e32c..00000000 --- a/tests/gopher/orch/agent_runnable_test.cc +++ /dev/null @@ -1,483 +0,0 @@ -// 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/tests/gopher/orch/agent_state_test.cc b/tests/gopher/orch/agent_state_test.cc deleted file mode 100644 index 289354d5..00000000 --- a/tests/gopher/orch/agent_state_test.cc +++ /dev/null @@ -1,392 +0,0 @@ -// 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/tests/gopher/orch/agent_test.cc b/tests/gopher/orch/agent_test.cc deleted file mode 100644 index 9123f752..00000000 --- a/tests/gopher/orch/agent_test.cc +++ /dev/null @@ -1,462 +0,0 @@ -// 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/tests/gopher/orch/callback_manager_test.cc b/tests/gopher/orch/callback_manager_test.cc deleted file mode 100644 index c026cd09..00000000 --- a/tests/gopher/orch/callback_manager_test.cc +++ /dev/null @@ -1,499 +0,0 @@ -// 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/tests/gopher/orch/circuit_breaker_test.cc b/tests/gopher/orch/circuit_breaker_test.cc deleted file mode 100644 index 40117486..00000000 --- a/tests/gopher/orch/circuit_breaker_test.cc +++ /dev/null @@ -1,96 +0,0 @@ -// 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/tests/gopher/orch/fallback_test.cc b/tests/gopher/orch/fallback_test.cc deleted file mode 100644 index 9f78bf31..00000000 --- a/tests/gopher/orch/fallback_test.cc +++ /dev/null @@ -1,102 +0,0 @@ -// 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/tests/gopher/orch/human_approval_test.cc b/tests/gopher/orch/human_approval_test.cc deleted file mode 100644 index 04e06625..00000000 --- a/tests/gopher/orch/human_approval_test.cc +++ /dev/null @@ -1,434 +0,0 @@ -// 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/tests/gopher/orch/integration_test.cc b/tests/gopher/orch/integration_test.cc deleted file mode 100644 index e33042e0..00000000 --- a/tests/gopher/orch/integration_test.cc +++ /dev/null @@ -1,81 +0,0 @@ -// 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/tests/gopher/orch/lambda_test.cc b/tests/gopher/orch/lambda_test.cc deleted file mode 100644 index dd4c2e21..00000000 --- a/tests/gopher/orch/lambda_test.cc +++ /dev/null @@ -1,73 +0,0 @@ -// 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/tests/gopher/orch/llm_provider_test.cc b/tests/gopher/orch/llm_provider_test.cc deleted file mode 100644 index 57b515fb..00000000 --- a/tests/gopher/orch/llm_provider_test.cc +++ /dev/null @@ -1,284 +0,0 @@ -// 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/tests/gopher/orch/llm_runnable_test.cc b/tests/gopher/orch/llm_runnable_test.cc deleted file mode 100644 index d1cae780..00000000 --- a/tests/gopher/orch/llm_runnable_test.cc +++ /dev/null @@ -1,332 +0,0 @@ -// 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/tests/gopher/orch/mcp_server_test.cc b/tests/gopher/orch/mcp_server_test.cc deleted file mode 100644 index 8f8f11ba..00000000 --- a/tests/gopher/orch/mcp_server_test.cc +++ /dev/null @@ -1,106 +0,0 @@ -// 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/tests/gopher/orch/mock_http_client.h b/tests/gopher/orch/mock_http_client.h deleted file mode 100644 index 62d1dded..00000000 --- a/tests/gopher/orch/mock_http_client.h +++ /dev/null @@ -1,232 +0,0 @@ -// 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/tests/gopher/orch/mock_llm_provider.h b/tests/gopher/orch/mock_llm_provider.h deleted file mode 100644 index ff0fa33b..00000000 --- a/tests/gopher/orch/mock_llm_provider.h +++ /dev/null @@ -1,238 +0,0 @@ -// 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/tests/gopher/orch/mock_server_test.cc b/tests/gopher/orch/mock_server_test.cc deleted file mode 100644 index ca2d5daf..00000000 --- a/tests/gopher/orch/mock_server_test.cc +++ /dev/null @@ -1,105 +0,0 @@ -// 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/tests/gopher/orch/orch_test_fixture.h b/tests/gopher/orch/orch_test_fixture.h deleted file mode 100644 index 752448bd..00000000 --- a/tests/gopher/orch/orch_test_fixture.h +++ /dev/null @@ -1,95 +0,0 @@ -#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/tests/gopher/orch/parallel_test.cc b/tests/gopher/orch/parallel_test.cc deleted file mode 100644 index 9434cade..00000000 --- a/tests/gopher/orch/parallel_test.cc +++ /dev/null @@ -1,84 +0,0 @@ -// 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/tests/gopher/orch/rest_server_test.cc b/tests/gopher/orch/rest_server_test.cc deleted file mode 100644 index f054fe9e..00000000 --- a/tests/gopher/orch/rest_server_test.cc +++ /dev/null @@ -1,517 +0,0 @@ -// 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/tests/gopher/orch/retry_test.cc b/tests/gopher/orch/retry_test.cc deleted file mode 100644 index ee07359a..00000000 --- a/tests/gopher/orch/retry_test.cc +++ /dev/null @@ -1,85 +0,0 @@ -// 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/tests/gopher/orch/router_test.cc b/tests/gopher/orch/router_test.cc deleted file mode 100644 index a7fbb128..00000000 --- a/tests/gopher/orch/router_test.cc +++ /dev/null @@ -1,110 +0,0 @@ -// 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/tests/gopher/orch/sequence_test.cc b/tests/gopher/orch/sequence_test.cc deleted file mode 100644 index 5f792473..00000000 --- a/tests/gopher/orch/sequence_test.cc +++ /dev/null @@ -1,87 +0,0 @@ -// 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/tests/gopher/orch/server_composite_test.cc b/tests/gopher/orch/server_composite_test.cc deleted file mode 100644 index 07095fcb..00000000 --- a/tests/gopher/orch/server_composite_test.cc +++ /dev/null @@ -1,372 +0,0 @@ -// 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/tests/gopher/orch/state_graph_test.cc b/tests/gopher/orch/state_graph_test.cc deleted file mode 100644 index be84ae3b..00000000 --- a/tests/gopher/orch/state_graph_test.cc +++ /dev/null @@ -1,376 +0,0 @@ -// 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/tests/gopher/orch/state_machine_test.cc b/tests/gopher/orch/state_machine_test.cc deleted file mode 100644 index a69357d1..00000000 --- a/tests/gopher/orch/state_machine_test.cc +++ /dev/null @@ -1,226 +0,0 @@ -// 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/tests/gopher/orch/timeout_test.cc b/tests/gopher/orch/timeout_test.cc deleted file mode 100644 index 382c67be..00000000 --- a/tests/gopher/orch/timeout_test.cc +++ /dev/null @@ -1,64 +0,0 @@ -// 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/tests/gopher/orch/tool_registry_test.cc b/tests/gopher/orch/tool_registry_test.cc deleted file mode 100644 index a6b29da2..00000000 --- a/tests/gopher/orch/tool_registry_test.cc +++ /dev/null @@ -1,766 +0,0 @@ -// 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/tests/gopher/orch/tool_runnable_test.cc b/tests/gopher/orch/tool_runnable_test.cc deleted file mode 100644 index 3df740cc..00000000 --- a/tests/gopher/orch/tool_runnable_test.cc +++ /dev/null @@ -1,389 +0,0 @@ -// 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/tests/orch/hello_test.cpp b/tests/orch/hello_test.cpp deleted file mode 100644 index a6672772..00000000 --- a/tests/orch/hello_test.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#include "orch/core/hello.h" - -#include -#include - -#include "orch/core/version.h" - -using namespace gopher::orch::core; -using namespace testing; - -class HelloTest : public ::testing::Test { - protected: - void SetUp() override { - // Setup code if needed - } - - void TearDown() override { - // Teardown code if needed - } -}; - -TEST_F(HelloTest, DefaultConstructor) { - Hello hello; - EXPECT_EQ(hello.greet(), "Hello, World!"); - EXPECT_EQ(hello.get_name(), "World"); -} - -TEST_F(HelloTest, ParameterizedConstructor) { - Hello hello("Alice"); - EXPECT_EQ(hello.greet(), "Hello, Alice!"); - EXPECT_EQ(hello.get_name(), "Alice"); -} - -TEST_F(HelloTest, SetName) { - Hello hello; - hello.set_name("Bob"); - EXPECT_EQ(hello.greet(), "Hello, Bob!"); - EXPECT_EQ(hello.get_name(), "Bob"); -} - -TEST_F(HelloTest, GreetWithPrefix) { - Hello hello("Charlie"); - EXPECT_EQ(hello.greet_with_prefix("Hi"), "Hi Charlie!"); - EXPECT_EQ(hello.greet_with_prefix("Welcome"), "Welcome Charlie!"); -} - -TEST_F(HelloTest, GetVersion) { EXPECT_EQ(Hello::get_version(), "0.1.0"); } - -TEST_F(HelloTest, EmptyName) { - Hello hello(""); - EXPECT_EQ(hello.greet(), "Hello, !"); - EXPECT_EQ(hello.get_name(), ""); -} - -TEST_F(HelloTest, SpecialCharacters) { - Hello hello("User@123!"); - EXPECT_EQ(hello.greet(), "Hello, User@123!!"); - EXPECT_EQ(hello.get_name(), "User@123!"); -} - -TEST_F(HelloTest, LongName) { - std::string long_name(1000, 'a'); - Hello hello(long_name); - EXPECT_EQ(hello.get_name(), long_name); - EXPECT_THAT(hello.greet(), StartsWith("Hello, ")); - EXPECT_THAT(hello.greet(), EndsWith("!")); -} - -// Test HelloBuilder -class HelloBuilderTest : public ::testing::Test { - protected: - HelloBuilder builder; -}; - -TEST_F(HelloBuilderTest, DefaultBuild) { - auto hello = builder.build(); - EXPECT_EQ(hello->greet(), "Hello, World!"); -} - -TEST_F(HelloBuilderTest, WithName) { - auto hello = builder.with_name("Diana").build(); - EXPECT_EQ(hello->greet(), "Hello, Diana!"); -} - -TEST_F(HelloBuilderTest, ChainedCalls) { - auto hello = builder.with_name("Eve").with_greeting_style("formal").build(); - EXPECT_EQ(hello->greet(), "Hello, Eve!"); -} - -TEST_F(HelloBuilderTest, MultipleBuildsSameBuilder) { - builder.with_name("Frank"); - auto hello1 = builder.build(); - auto hello2 = builder.build(); - - EXPECT_EQ(hello1->greet(), "Hello, Frank!"); - EXPECT_EQ(hello2->greet(), "Hello, Frank!"); - - // Verify they are independent objects - hello1->set_name("George"); - EXPECT_EQ(hello1->get_name(), "George"); - EXPECT_EQ(hello2->get_name(), "Frank"); -} - -// Version tests -TEST(VersionTest, VersionConstants) { - EXPECT_EQ(Version::major(), 0); - EXPECT_EQ(Version::minor(), 1); - EXPECT_EQ(Version::patch(), 0); - EXPECT_STREQ(Version::string(), "0.1.0"); -} diff --git a/third_party/gopher-mcp b/third_party/gopher-mcp deleted file mode 160000 index bcd64eb4..00000000 --- a/third_party/gopher-mcp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bcd64eb4c105f7c52d0a2033651979914c07e8b7 From 9fc2983488e43c642ac74e5ccc202d4b7459e0b9 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Wed, 14 Jan 2026 23:47:47 +0800 Subject: [PATCH 192/197] Add gopher-orch submodule and build script for TypeScript SDK (#1) This commit sets up the foundation for the TypeScript SDK by adding the native C++ implementation as a submodule and creating an automated build script. Changes: 1. Add gopher-orch as Git Submodule 2. Create build.sh Script 3. Add .gitignore Build Script Features: - Colored terminal output for better readability - Error handling (exits on first error) - Progress indicators for each build step - Automatic CPU core detection for parallel builds - Cross-platform library detection (.dylib for macOS, .so for Linux) - Helpful next steps guide after successful build --- .gitignore | 39 ++++++++++++++++++ .gitmodules | 4 ++ build.sh | 91 +++++++++++++++++++++++++++++++++++++++++ third_party/gopher-orch | 1 + 4 files changed, 135 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100755 build.sh create mode 160000 third_party/gopher-orch diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..036f8e23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Build artifacts +build/ +dist/ +lib/ +*.node +*.dylib +*.so +*.dll + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + +# TypeScript +*.tsbuildinfo + +# Testing +coverage/ +.nyc_output/ + +# OS +.DS_Store +Thumbs.db + +# Misc +*.log +.env +.env.local diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..23ddfa4d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "third_party/gopher-orch"] + path = third_party/gopher-orch + url = https://github.com/GopherSecurity/gopher-orch.git + branch = dev_agent diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..c69115d0 --- /dev/null +++ b/build.sh @@ -0,0 +1,91 @@ +#!/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 + +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}Building gopher-orch TypeScript SDK${NC}" +echo -e "${GREEN}======================================${NC}" +echo "" + +# 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" + +# Step 1: Update submodules recursively +echo -e "${YELLOW}Step 1: Updating submodules recursively...${NC}" +git submodule update --init --recursive +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 . + +echo -e "${GREEN}✓ Native library built successfully${NC}" +echo "" + +# Step 4: Verify build artifacts +echo -e "${YELLOW}Step 3: Verifying 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 "" +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}Build completed successfully!${NC}" +echo -e "${GREEN}======================================${NC}" +echo "" +echo -e "Next steps:" +echo -e " 1. Install TypeScript dependencies: ${YELLOW}npm install${NC}" +echo -e " 2. Build TypeScript SDK: ${YELLOW}npm run build${NC}" +echo -e " 3. Run tests: ${YELLOW}npm test${NC}" diff --git a/third_party/gopher-orch b/third_party/gopher-orch new file mode 160000 index 00000000..88bcdd75 --- /dev/null +++ b/third_party/gopher-orch @@ -0,0 +1 @@ +Subproject commit 88bcdd75cad1b2d0eef19aeeac0c13154823b0ff From facbc1cc65b0543be3f0fcb4a574351820ddb546 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 15 Jan 2026 00:57:49 +0800 Subject: [PATCH 193/197] Update README with correct GopherAgent API usage (#1) Fix documentation to reflect the actual TypeScript SDK API: - Use GopherAgent.create() instead of fictional Agent class - Update imports to use @gopher/orch package name - Add ServerConfig utility class documentation - Add error handling examples - Update all code examples to match real implementation --- README.md | 315 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..14735e0a --- /dev/null +++ b/README.md @@ -0,0 +1,315 @@ +# @gopher/orch - TypeScript SDK + +TypeScript SDK for Gopher Orch - AI Agent orchestration framework with native C++ performance. + +## Features + +- 🚀 **Native Performance** - Powered by C++ core with TypeScript bindings +- 🤖 **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 servers +- 🔄 **State Management** - Built-in state graph for complex workflows +- 💪 **Type Safety** - Full TypeScript type definitions + +## Installation + +```bash +npm install @gopher/orch +``` + +## Quick Start + +```typescript +import { GopherAgent } from '@gopher/orch'; + +// Create an agent with API key (fetches server config from remote API) +const agent = GopherAgent.create({ + provider: 'AnthropicProvider', + model: 'claude-3-haiku-20240307', + apiKey: 'your-api-key' +}); + +// Run the agent +const result = agent.run('What is the weather in Tokyo?'); +console.log(result); + +// Cleanup (optional - happens automatically on exit) +agent.dispose(); +``` + +## Architecture + +``` +TypeScript SDK (gopher-orch-js) + | + | FFI Bindings + v +Native Library (gopher-orch) + | + +-- Agent Framework + +-- LLM Providers (Anthropic, OpenAI) + +-- MCP Client/Server + +-- Tool Registry + +-- State Graph +``` + +## Building from Source + +### Prerequisites + +- Node.js >= 16 +- CMake >= 3.15 +- C++14 compatible compiler +- Git + +### Build Steps + +```bash +# Clone the repository +git clone https://github.com/GopherSecurity/gopher-orch-js.git +cd gopher-orch-js + +# Initialize submodules +git submodule update --init --recursive + +# Build native library and TypeScript +npm install +npm run build + +# Run tests +npm test +``` + +## API Documentation + +### GopherAgent + +The main class for creating and running AI agents: + +```typescript +import { GopherAgent } from '@gopher/orch'; + +// Initialize the library (called automatically on first create) +GopherAgent.init(); + +// 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": {...}}' +}); + +// Run a query +const result = agent.run('Your prompt here'); + +// Run with custom timeout (default: 60000ms) +const result = agent.run('Your prompt here', 30000); + +// Run with detailed result information +const detailed = agent.runDetailed('Your prompt here'); +// Returns: { response, status: 'success' | 'error' | 'timeout', iterationCount?, tokensUsed? } + +// Cleanup (optional - happens automatically on exit) +agent.dispose(); + +// Shutdown library (optional - happens automatically on exit) +GopherAgent.shutdown(); +``` + +### ServerConfig + +Utility class for working with server configurations: + +```typescript +import { ServerConfig } from '@gopher/orch'; + +// Fetch MCP server configurations from remote API +const config = ServerConfig.fetch('your-api-key'); + +// Create default configuration for local development +const defaultConfig = ServerConfig.createDefault(); +``` + +### Error Handling + +The SDK provides typed errors for different failure scenarios: + +```typescript +import { AgentError, ApiKeyError, ConnectionError, TimeoutError } from '@gopher/orch'; + +try { + const agent = GopherAgent.create({ provider, model, apiKey }); + const result = agent.run('query'); +} catch (error) { + if (error instanceof ApiKeyError) { + console.error('Invalid API key'); + } else if (error instanceof ConnectionError) { + console.error('Failed to connect to MCP servers'); + } else if (error instanceof TimeoutError) { + console.error('Query timed out'); + } else if (error instanceof AgentError) { + console.error('Agent error:', error.message); + } +} +``` + +## Examples + +### Basic Usage with API Key + +```typescript +import { GopherAgent } from '@gopher/orch'; + +async function main() { + // Create agent with API key (fetches server config from remote API) + const agent = GopherAgent.create({ + provider: 'AnthropicProvider', + model: 'claude-3-haiku-20240307', + apiKey: 'your-api-key' + }); + + const question = 'What time is it in London?'; + console.log(`Question: ${question}`); + + const answer = agent.run(question); + console.log('Answer:', answer); + + // Cleanup (optional - happens automatically on exit) + agent.dispose(); +} + +main().catch(error => { + console.error('Error:', error.message); + process.exit(1); +}); +``` + +### Using JSON Server Config + +```typescript +import { GopherAgent, ServerConfig } from '@gopher/orch'; + +// Use default local development config +const serverConfig = ServerConfig.createDefault(); + +const agent = GopherAgent.create({ + provider: 'AnthropicProvider', + model: 'claude-3-haiku-20240307', + serverConfig: serverConfig +}); + +const result = agent.run('What is 2 + 2?'); +console.log(result); + +agent.dispose(); +``` + +### With Detailed Results + +```typescript +import { GopherAgent } from '@gopher/orch'; + +const agent = GopherAgent.create({ + provider: 'AnthropicProvider', + model: 'claude-3-haiku-20240307', + apiKey: 'your-api-key' +}); + +const result = agent.runDetailed('Explain quantum computing'); + +if (result.status === 'success') { + console.log('Response:', result.response); + console.log('Iterations:', result.iterationCount); +} else if (result.status === 'timeout') { + console.log('Query timed out'); +} else { + console.log('Error:', result.response); +} + +agent.dispose(); +``` + +## Development + +### Project Structure + +``` +gopher-orch-js/ +├── src/ # TypeScript source +│ ├── agent/ # Agent implementation +│ ├── llm/ # LLM provider interfaces +│ ├── mcp/ # MCP client/server +│ ├── tools/ # Tool management +│ ├── native/ # FFI bindings +│ └── index.ts # Main entry point +├── native/ # Built native libraries +│ ├── lib/ # Shared libraries (.dylib, .so) +│ └── include/ # C++ headers +├── third_party/ # Native dependencies +│ └── gopher-orch/ # C++ implementation (submodule) +├── dist/ # Compiled TypeScript +├── build.sh # Native build script +├── package.json # NPM configuration +└── tsconfig.json # TypeScript configuration +``` + +### Build Scripts + +- `npm run build:native` - Build native C++ library +- `npm run build` - Build TypeScript (automatically builds native first) +- `npm run watch` - Watch mode for TypeScript +- `npm test` - Run tests +- `npm run lint` - Lint TypeScript code +- `npm run clean` - Clean all build artifacts + +### Running Tests + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch + +# Run specific test file +npm test -- agent.test.ts +``` + +## Contributing + +Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +MIT License - see [LICENSE](LICENSE) file for details. + +## Links + +- [GitHub Repository](https://github.com/GopherSecurity/gopher-orch-js) +- [Native C++ Implementation](https://github.com/GopherSecurity/gopher-orch) +- [Documentation](https://github.com/GopherSecurity/gopher-orch-js/docs) +- [Examples](https://github.com/GopherSecurity/gopher-orch-js/examples) + +## Support + +- GitHub Issues: [Report a bug](https://github.com/GopherSecurity/gopher-orch-js/issues) +- Discussions: [Ask a question](https://github.com/GopherSecurity/gopher-orch-js/discussions) + +## Acknowledgments + +- Built on top of [gopher-orch](https://github.com/GopherSecurity/gopher-orch) C++ framework +- Inspired by LangChain and LangGraph +- Uses [Model Context Protocol](https://modelcontextprotocol.io/) From f004daea767d61462cefea993d77ddf9588a7646 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 15 Jan 2026 01:15:30 +0800 Subject: [PATCH 194/197] Add FFI bindings to call native C++ functions (#1) Set up TypeScript SDK with FFI bindings using koffi: - Add src/ffi.ts with platform-aware library loading and graceful fallbacks - Add src/types.ts with type definitions and error classes - Add src/agent.ts with GopherAgent and ServerConfig classes - Add src/index.ts with main exports - Update package.json to use koffi instead of ffi-napi - Update tsconfig.json to use ESM modules (NodeNext) --- package.json | 63 ++++++++ src/agent.ts | 347 ++++++++++++++++++++++++++++++++++++++++++ src/ffi.ts | 407 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 35 +++++ src/types.ts | 93 ++++++++++++ tsconfig.json | 20 +++ 6 files changed, 965 insertions(+) create mode 100644 package.json create mode 100644 src/agent.ts create mode 100644 src/ffi.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 00000000..3f2accfc --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@gopher/orch", + "version": "0.1.0", + "description": "TypeScript SDK for Gopher Orch - AI Agent orchestration framework with native performance", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build:native": "./build.sh", + "prebuild": "npm run build:native", + "build": "tsc", + "watch": "tsc --watch", + "test": "jest", + "test:watch": "jest --watch", + "lint": "eslint src/**/*.ts", + "format": "prettier --write \"src/**/*.ts\"", + "clean": "rm -rf dist build native third_party/gopher-orch/build", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "ai", + "agent", + "llm", + "mcp", + "orchestration", + "langchain", + "anthropic", + "openai", + "typescript", + "native" + ], + "author": "Gopher Security", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/GopherSecurity/gopher-orch-js.git" + }, + "engines": { + "node": ">=16.0.0" + }, + "files": [ + "dist", + "native/lib", + "native/include", + "build.sh", + "README.md", + "LICENSE" + ], + "devDependencies": { + "@types/node": "^20.11.0", + "@types/jest": "^29.5.11", + "@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" + }, + "dependencies": { + "koffi": "^2.8.0" + } +} diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 00000000..43472535 --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,347 @@ +/** + * @file agent.ts + * @brief TypeScript wrapper for GopherAgent functionality + */ + +import { library, initializeLibrary, shutdownLibrary } from './ffi.js'; +import { + AgentResult, + AgentError, + ApiKeyError, + 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; +} + +// Agent handle type +interface AgentHandle { + handle: unknown; + isNull: () => boolean; +} + +/** + * 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"; + * + * // 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: AgentHandle | null; + private disposed: boolean = false; + private static initialized: boolean = false; + + private constructor(handle: AgentHandle) { + 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: AgentHandle | null; + + 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()) { + // 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) { + if (error instanceof AgentError) { + throw error; + } + throw new AgentError(`Failed to create agent: ${(error as 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) { + throw new AgentError(`Query execution failed: ${(error as 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) { + if (error instanceof TimeoutError) { + return { + response: error.message, + status: 'timeout', + }; + } else { + return { + response: (error as 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); + }); + } +} + +// Backward compatibility alias +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) { + if (error instanceof AgentError) { + throw error; + } + throw new AgentError(`Failed to fetch servers: ${(error as 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); + } +} + +// Backward compatibility alias +export { ServerConfig as ServerConfigHelper }; diff --git a/src/ffi.ts b/src/ffi.ts new file mode 100644 index 00000000..051fc605 --- /dev/null +++ b/src/ffi.ts @@ -0,0 +1,407 @@ +/** + * @file ffi.ts + * @brief 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: [ + // Primary: native/lib relative to project root (from dist/) + join(__dirname, '../native/lib/libgopher-orch.dylib'), + // Development: when running from src/ + join(__dirname, '../../native/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: [ + join(__dirname, '../native/lib/libgopher-orch.dylib'), + join(__dirname, '../../native/lib/libgopher-orch.dylib'), + '/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, '../native/lib/libgopher-orch.so'), + join(__dirname, '../../native/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, '../native/lib/libgopher-orch.so'), + join(__dirname, '../../native/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, '../native/lib/gopher-orch.dll'), + join(__dirname, '../../native/lib/gopher-orch.dll'), + 'C:\\Program Files\\gopher-orch\\bin\\gopher-orch.dll', + 'C:\\Program Files\\gopher-orch\\lib\\gopher-orch.dll', + ], + }, + }, +} as const; + +/** + * Get the path to the native library + */ +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('\n - '); + throw new Error( + `Gopher-Orch library not found. Searched paths:\n - ${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 override.` + ); +} + +// Raw library bindings +export let gopherOrchLib: Record unknown> = {}; + +// Error info struct type for koffi +let ErrorInfoType: unknown = null; + +try { + const libPath = getLibraryPath(); + + // Load the shared library + const nativeLib = 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', + }); + + // List of C API functions to bind + const functionList = [ + // Core initialization functions + { name: 'gopher_orch_init', signature: 'int', args: [] as string[] }, + { name: 'gopher_orch_shutdown', signature: 'void', args: [] as string[] }, + { name: 'gopher_orch_is_initialized', signature: 'int', args: [] as string[] }, + { name: 'gopher_orch_last_error', signature: 'gopher_orch_error_info_t*', args: [] as string[] }, + { name: 'gopher_orch_clear_error', signature: 'void', args: [] as string[] }, + { name: 'gopher_orch_free', signature: 'void', args: ['void*'] }, + + // Agent functions + { + name: 'gopher_orch_agent_create_by_json', + signature: 'void*', + args: ['string', 'string', 'string'], // provider, model, server_json_config + }, + { + name: 'gopher_orch_agent_create_by_api_key', + signature: 'void*', + args: ['string', 'string', 'string'], // provider, model, api_key + }, + { + name: 'gopher_orch_agent_run', + signature: 'string', + 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', + args: ['string'], // api_key + }, + ]; + + // Try to bind each function + const availableFunctions: Record unknown> = {}; + for (const func of functionList) { + try { + availableFunctions[func.name] = nativeLib.func(func.name, func.signature, func.args); + } catch { + // 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 = {}; +} + +// Agent handle wrapper type +interface AgentHandle { + handle: unknown; + isNull: () => boolean; +} + +/** + * FFI interface with fallbacks for when native library is unavailable + */ +export const library = { + gopher_orch_init: (): number => { + if (gopherOrchLib.gopher_orch_init) { + return gopherOrchLib.gopher_orch_init() as number; + } + return 0; // Success fallback + }, + + gopher_orch_shutdown: (): void => { + if (gopherOrchLib.gopher_orch_shutdown) { + gopherOrchLib.gopher_orch_shutdown(); + } + }, + + gopher_orch_is_initialized: (): boolean => { + if (gopherOrchLib.gopher_orch_is_initialized) { + return (gopherOrchLib.gopher_orch_is_initialized() as number) !== 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) { + const errorInfo = koffi.decode(errorPtr, ErrorInfoType as koffi.IKoffiCType) as { + message?: string; + }; + if (errorInfo && errorInfo.message) { + return errorInfo.message; + } + } + } catch { + // Silently fail - will return null + } + } + return null; + }, + + gopher_orch_clear_error: (): void => { + if (gopherOrchLib.gopher_orch_clear_error) { + gopherOrchLib.gopher_orch_clear_error(); + } + }, + + gopher_orch_free: (ptr: unknown): void => { + if (gopherOrchLib.gopher_orch_free && ptr) { + gopherOrchLib.gopher_orch_free(ptr); + } + }, + + gopher_orch_agent_create_by_json: ( + provider: string, + model: string, + serverJson: string + ): AgentHandle | null => { + 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) { + console.error('FFI Error in agent creation:', ffiError); + 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 + ): AgentHandle | null => { + if (gopherOrchLib.gopher_orch_agent_create_by_api_key) { + // 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, + }; + + 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: AgentHandle | null): void => { + if (gopherOrchLib.gopher_orch_agent_release && agent?.handle) { + gopherOrchLib.gopher_orch_agent_release(agent.handle); + } + }, + + gopher_orch_agent_run: ( + agent: AgentHandle | null, + query: string, + timeoutMs: number = 30000 + ): string => { + if (gopherOrchLib.gopher_orch_agent_run && agent?.handle) { + const result = gopherOrchLib.gopher_orch_agent_run(agent.handle, query, timeoutMs); + return (result as string) || `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): string => { + if (gopherOrchLib.gopher_orch_api_fetch_servers) { + return gopherOrchLib.gopher_orch_api_fetch_servers(apiKey) as string; + } + // 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, + }, + ], + }, + }); + }, +}; + +/** + * Initialize the native library + */ +export function initializeLibrary(): boolean { + return library.gopher_orch_init() === 0; +} + +/** + * Shutdown the native library + */ +export function shutdownLibrary(): void { + library.gopher_orch_shutdown(); +} + +/** + * Get the last error message + */ +export function getLastError(): string | null { + return library.gopher_orch_last_error(); +} + +/** + * Clear the last error + */ +export function clearError(): void { + library.gopher_orch_clear_error(); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..2d4b9a1f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,35 @@ +/** + * @file index.ts + * @brief Main entry point for @gopher/orch TypeScript SDK + * + * @example + * ```typescript + * import { GopherAgent } from '@gopher/orch'; + * + * const agent = GopherAgent.create({ + * provider: 'AnthropicProvider', + * model: 'claude-3-haiku-20240307', + * apiKey: 'your-api-key' + * }); + * + * const answer = agent.run('What time is it in Tokyo?'); + * console.log(answer); + * + * agent.dispose(); + * ``` + */ + +// Main classes +export { GopherAgent, GopherAgentConfig, ServerConfig } from './agent.js'; + +// Backward compatibility aliases +export { ReActAgent, ServerConfigHelper } from './agent.js'; + +// Type definitions +export * from './types.js'; + +// Low-level FFI access for advanced usage +export { library, initializeLibrary, shutdownLibrary, getLastError, clearError } from './ffi.js'; + +// Version +export const version = '0.1.0'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..9c399bc2 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,93 @@ +/** + * @file types.ts + * @brief TypeScript type definitions for gopher-orch SDK + */ + +/** + * MCP server configuration + */ +export interface ServerConfig { + version: string; + serverId: string; + name: string; + transport: string; + config: { + url: string; + headers: Record; + }; + connectTimeout: number; + requestTimeout: number; +} + +/** + * API response structure from server config fetch + */ +export interface ApiResponse { + succeeded: boolean; + code: number; + message: string; + data: { + servers: ServerConfig[]; + }; +} + +/** + * Agent configuration options + */ +export interface AgentConfig { + provider: string; + model: string; + systemPrompt?: string; + maxIterations?: number; + temperature?: number; +} + +/** + * Result from agent query execution + */ +export interface AgentResult { + response: string; + status: 'success' | 'error' | 'timeout'; + iterationCount?: number; + tokensUsed?: number; +} + +/** + * Base error class for agent operations + */ +export class AgentError extends Error { + constructor(message: string, public code?: string) { + super(message); + this.name = 'AgentError'; + } +} + +/** + * Error for API key related issues + */ +export class ApiKeyError extends AgentError { + constructor(message: string = 'Invalid or missing API key') { + super(message, 'API_KEY_ERROR'); + this.name = 'ApiKeyError'; + } +} + +/** + * Error for MCP server connection issues + */ +export class ConnectionError extends AgentError { + constructor(message: string = 'Failed to connect to MCP servers') { + super(message, 'CONNECTION_ERROR'); + this.name = 'ConnectionError'; + } +} + +/** + * Error for operation timeout + */ +export class TimeoutError extends AgentError { + constructor(message: string = 'Agent execution timed out') { + super(message, 'TIMEOUT_ERROR'); + this.name = 'TimeoutError'; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..eb126d9e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} From f7ef35bc5e24b23015d26c9bdad1f7407fc33e8a Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 15 Jan 2026 01:19:57 +0800 Subject: [PATCH 195/197] Update build.sh to copy dependency libraries (#1) Add step to copy gopher-mcp and fmt shared libraries to native/lib/ after cmake install. These are runtime dependencies of libgopher-orch. --- build.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/build.sh b/build.sh index c69115d0..5735dbd5 100755 --- a/build.sh +++ b/build.sh @@ -58,6 +58,19 @@ cmake --build . --config Release -j$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/d 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 + echo -e "${GREEN}✓ Native library built successfully${NC}" echo "" From 26760cadfc9966abbe0eaa8657a19409c2a3115d Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 15 Jan 2026 01:22:40 +0800 Subject: [PATCH 196/197] Update build.sh to also build TypeScript SDK (#1) Add steps to install npm dependencies and compile TypeScript after building the native library. Uses npx tsc directly to avoid prebuild recursion since package.json's prebuild calls build.sh --- build.sh | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/build.sh b/build.sh index 5735dbd5..16b86be8 100755 --- a/build.sh +++ b/build.sh @@ -93,12 +93,38 @@ else echo -e "${YELLOW}⚠ Include directory not found: ${NATIVE_INCLUDE_DIR}${NC}" fi +echo "" + +# Step 5: Build TypeScript SDK +echo -e "${YELLOW}Step 4: Building TypeScript SDK...${NC}" +cd "${SCRIPT_DIR}" + +# Install npm dependencies if node_modules doesn't exist +if [ ! -d "node_modules" ]; then + echo -e "${YELLOW} Installing npm dependencies...${NC}" + npm install --ignore-scripts +fi + +# Compile TypeScript (use npx tsc directly to avoid prebuild recursion) +echo -e "${YELLOW} Compiling TypeScript...${NC}" +npx tsc + +echo -e "${GREEN}✓ TypeScript SDK built successfully${NC}" +echo "" + +# Step 6: Verify TypeScript build +echo -e "${YELLOW}Step 5: Verifying TypeScript build...${NC}" +if [ -d "${SCRIPT_DIR}/dist" ]; then + echo -e "${GREEN}✓ TypeScript compiled to: ${SCRIPT_DIR}/dist${NC}" + ls -lh "${SCRIPT_DIR}/dist"/*.js 2>/dev/null | head -5 +else + echo -e "${RED}✗ dist directory not found${NC}" + exit 1 +fi + echo "" echo -e "${GREEN}======================================${NC}" echo -e "${GREEN}Build completed successfully!${NC}" echo -e "${GREEN}======================================${NC}" echo "" -echo -e "Next steps:" -echo -e " 1. Install TypeScript dependencies: ${YELLOW}npm install${NC}" -echo -e " 2. Build TypeScript SDK: ${YELLOW}npm run build${NC}" -echo -e " 3. Run tests: ${YELLOW}npm test${NC}" +echo -e "Run tests: ${YELLOW}npm test${NC}" From 0786a87225c33e8e6210c47487373c2f980da811 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 15 Jan 2026 02:17:15 +0800 Subject: [PATCH 197/197] Add examples and remove ServerConfig.createDefault from SDK (#1) - Add examples/client_example_json.ts demonstrating JSON server config - Add examples/client_example_json_run.sh to build and run example - Remove ServerConfig.createDefault() from SDK (belongs in examples) --- examples/client_example_json.ts | 54 +++++++++++++++++++++++++++++ examples/client_example_json_run.sh | 3 ++ src/agent.ts | 44 +---------------------- 3 files changed, 58 insertions(+), 43 deletions(-) create mode 100644 examples/client_example_json.ts create mode 100755 examples/client_example_json_run.sh diff --git a/examples/client_example_json.ts b/examples/client_example_json.ts new file mode 100644 index 00000000..bb605e59 --- /dev/null +++ b/examples/client_example_json.ts @@ -0,0 +1,54 @@ +/** + * @file client_example_json.ts + * @brief TypeScript example using JSON server configuration + */ +import { GopherAgent } from '../dist/index.js'; + +// Server configuration for local MCP servers +const serverConfig = JSON.stringify({ + 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/rpc', headers: {} }, + connectTimeout: 5000, + requestTimeout: 30000, + }, + { + version: '2025-01-09', + serverId: '2', + name: 'server2', + transport: 'http_sse', + config: { url: 'http://127.0.0.1:3002/rpc', headers: {} }, + connectTimeout: 5000, + requestTimeout: 30000, + }, + ], + }, +}); + +async function main(): Promise { + const provider = 'AnthropicProvider'; + const model = 'claude-3-haiku-20240307'; + const agent = GopherAgent.create({ provider, model, serverConfig }); + console.log('GopherAgent created!'); + + const args = process.argv.slice(2); + const question = (args.length > 0) ? + args[0] : 'What is the weather like in New York?'; + console.log(`Question: ${question}`); + const answer = agent.run(question); + console.log('Answer:'); + console.log(answer); +} + +main().catch(error => { + console.error('Error:', (error as Error).message); + process.exit(1); +}); diff --git a/examples/client_example_json_run.sh b/examples/client_example_json_run.sh new file mode 100755 index 00000000..cb97570f --- /dev/null +++ b/examples/client_example_json_run.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd "$(dirname "$0")/.." +npx tsc && npx tsx examples/client_example_json.ts "$@" diff --git a/src/agent.ts b/src/agent.ts index 43472535..0590fc72 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -9,7 +9,6 @@ import { AgentError, ApiKeyError, TimeoutError, - ApiResponse, } from './types.js'; /** @@ -273,7 +272,7 @@ export class GopherAgent { export { GopherAgent as ReActAgent }; /** - * Utility functions for working with server configurations + * Utility class for fetching server configurations */ export class ServerConfig { /** @@ -300,47 +299,6 @@ export class ServerConfig { throw new AgentError(`Failed to fetch servers: ${(error as 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); - } } // Backward compatibility alias