From 72b93fcc2431c61eb07f77a98c8484572da8cbd4 Mon Sep 17 00:00:00 2001 From: Matt Spurlin Date: Fri, 26 Jun 2026 16:45:59 -0400 Subject: [PATCH 1/3] Replace Go example server with Dojo implementation --- .../go/example/client/internal/agent/chat.go | 2 +- sdks/community/go/example/server/README.md | 92 +++ sdks/community/go/example/server/cmd/main.go | 377 +++++++---- .../go/example/server/cmd/main_test.go | 109 +++ sdks/community/go/example/server/go.mod | 73 +-- sdks/community/go/example/server/go.sum | 357 +++------- .../server/internal/agent/agenticui.go | 145 ++++ .../server/internal/agent/agenticui_test.go | 111 ++++ .../example/server/internal/agent/convert.go | 221 +++++++ .../example/server/internal/agent/docstate.go | 91 +++ .../example/server/internal/agent/emitter.go | 215 ++++++ .../server/internal/agent/emitter_test.go | 102 +++ .../internal/agent/featureroutes_test.go | 142 ++++ .../server/internal/agent/fixreview_test.go | 131 ++++ .../internal/agent/frontendtools_test.go | 195 ++++++ .../go/example/server/internal/agent/loop.go | 618 ++++++++++++++++++ .../server/internal/agent/loop_test.go | 317 +++++++++ .../go/example/server/internal/agent/model.go | 35 + .../agent/multimodal_integration_test.go | 135 ++++ .../server/internal/agent/predictive.go | 157 +++++ .../server/internal/agent/predictive_test.go | 172 +++++ .../server/internal/agent/reasoning_test.go | 120 ++++ .../server/internal/agent/runconfig.go | 166 +++++ .../server/internal/agent/sharedstate.go | 225 +++++++ .../server/internal/agent/sharedstate_test.go | 120 ++++ .../go/example/server/internal/agent/state.go | 130 ++++ .../go/example/server/internal/agent/tools.go | 132 ++++ .../server/internal/agent/tools_test.go | 57 ++ .../server/internal/agentic/agentic.go | 99 --- .../agentic/agentic_integration_test.go | 66 -- .../internal/agentic/data/languages_prompt.md | 21 - .../server/internal/agentic/data/reminder.md | 2 - .../server/internal/agentic/handler.go | 308 --------- .../example/server/internal/audio/client.go | 140 ++++ .../example/server/internal/audio/handler.go | 102 +++ .../server/internal/audio/handler_test.go | 124 ++++ .../server/internal/audio/integration_test.go | 49 ++ .../server/internal/audio/testdata/savior.wav | Bin 0 -> 85548 bytes .../example/server/internal/config/config.go | 266 ++------ .../server/internal/document/client.go | 137 ++++ .../server/internal/document/handler.go | 119 ++++ .../server/internal/document/handler_test.go | 129 ++++ .../internal/document/integration_test.go | 49 ++ .../document/testdata/test_document.pdf | Bin 0 -> 588 bytes .../server/internal/imagegen/client.go | 113 ++++ .../server/internal/imagegen/handler.go | 118 ++++ .../go/example/server/internal/mcp/adapter.go | 52 -- .../go/example/server/internal/mcp/server.go | 109 --- .../example/server/internal/routes/routes.go | 131 ---- .../example/server/internal/runstore/store.go | 163 +++++ .../server/internal/runstore/store_test.go | 158 +++++ .../example/server/internal/vision/client.go | 123 ++++ .../example/server/internal/vision/handler.go | 119 ++++ .../server/internal/vision/handler_test.go | 119 ++++ .../internal/vision/integration_test.go | 58 ++ 55 files changed, 6195 insertions(+), 1426 deletions(-) create mode 100644 sdks/community/go/example/server/README.md create mode 100644 sdks/community/go/example/server/cmd/main_test.go create mode 100644 sdks/community/go/example/server/internal/agent/agenticui.go create mode 100644 sdks/community/go/example/server/internal/agent/agenticui_test.go create mode 100644 sdks/community/go/example/server/internal/agent/convert.go create mode 100644 sdks/community/go/example/server/internal/agent/docstate.go create mode 100644 sdks/community/go/example/server/internal/agent/emitter.go create mode 100644 sdks/community/go/example/server/internal/agent/emitter_test.go create mode 100644 sdks/community/go/example/server/internal/agent/featureroutes_test.go create mode 100644 sdks/community/go/example/server/internal/agent/fixreview_test.go create mode 100644 sdks/community/go/example/server/internal/agent/frontendtools_test.go create mode 100644 sdks/community/go/example/server/internal/agent/loop.go create mode 100644 sdks/community/go/example/server/internal/agent/loop_test.go create mode 100644 sdks/community/go/example/server/internal/agent/model.go create mode 100644 sdks/community/go/example/server/internal/agent/multimodal_integration_test.go create mode 100644 sdks/community/go/example/server/internal/agent/predictive.go create mode 100644 sdks/community/go/example/server/internal/agent/predictive_test.go create mode 100644 sdks/community/go/example/server/internal/agent/reasoning_test.go create mode 100644 sdks/community/go/example/server/internal/agent/runconfig.go create mode 100644 sdks/community/go/example/server/internal/agent/sharedstate.go create mode 100644 sdks/community/go/example/server/internal/agent/sharedstate_test.go create mode 100644 sdks/community/go/example/server/internal/agent/state.go create mode 100644 sdks/community/go/example/server/internal/agent/tools.go create mode 100644 sdks/community/go/example/server/internal/agent/tools_test.go delete mode 100644 sdks/community/go/example/server/internal/agentic/agentic.go delete mode 100644 sdks/community/go/example/server/internal/agentic/agentic_integration_test.go delete mode 100644 sdks/community/go/example/server/internal/agentic/data/languages_prompt.md delete mode 100644 sdks/community/go/example/server/internal/agentic/data/reminder.md delete mode 100644 sdks/community/go/example/server/internal/agentic/handler.go create mode 100644 sdks/community/go/example/server/internal/audio/client.go create mode 100644 sdks/community/go/example/server/internal/audio/handler.go create mode 100644 sdks/community/go/example/server/internal/audio/handler_test.go create mode 100644 sdks/community/go/example/server/internal/audio/integration_test.go create mode 100644 sdks/community/go/example/server/internal/audio/testdata/savior.wav create mode 100644 sdks/community/go/example/server/internal/document/client.go create mode 100644 sdks/community/go/example/server/internal/document/handler.go create mode 100644 sdks/community/go/example/server/internal/document/handler_test.go create mode 100644 sdks/community/go/example/server/internal/document/integration_test.go create mode 100644 sdks/community/go/example/server/internal/document/testdata/test_document.pdf create mode 100644 sdks/community/go/example/server/internal/imagegen/client.go create mode 100644 sdks/community/go/example/server/internal/imagegen/handler.go delete mode 100644 sdks/community/go/example/server/internal/mcp/adapter.go delete mode 100644 sdks/community/go/example/server/internal/mcp/server.go delete mode 100644 sdks/community/go/example/server/internal/routes/routes.go create mode 100644 sdks/community/go/example/server/internal/runstore/store.go create mode 100644 sdks/community/go/example/server/internal/runstore/store_test.go create mode 100644 sdks/community/go/example/server/internal/vision/client.go create mode 100644 sdks/community/go/example/server/internal/vision/handler.go create mode 100644 sdks/community/go/example/server/internal/vision/handler_test.go create mode 100644 sdks/community/go/example/server/internal/vision/integration_test.go diff --git a/sdks/community/go/example/client/internal/agent/chat.go b/sdks/community/go/example/client/internal/agent/chat.go index 7daeeab95e..a232996a2d 100644 --- a/sdks/community/go/example/client/internal/agent/chat.go +++ b/sdks/community/go/example/client/internal/agent/chat.go @@ -14,7 +14,7 @@ import ( ) func DefaultEndpoint() string { - return "http://localhost:8000/agentic" + return "http://localhost:8080/agentic" } func Chat(ctx context.Context, inputMsg string, endpoint string, send func(msg *message.Message)) error { diff --git a/sdks/community/go/example/server/README.md b/sdks/community/go/example/server/README.md new file mode 100644 index 0000000000..df406b8b58 --- /dev/null +++ b/sdks/community/go/example/server/README.md @@ -0,0 +1,92 @@ +# AG-UI Go Example Server + +This example is a Fiber v3 AG-UI server for local development and Dojo testing. +It replaces the older minimal `/agentic` example with a broader route surface +based on the Go Dojo server example. + +## Run + +Requires Go 1.25 or newer. Fiber v3.3.0 requires Go 1.25, so `go mod tidy` +updates this example module to `go 1.25.0`. + +```bash +cd sdks/community/go/example/server +OPENAI_API_KEY=... go run ./cmd +``` + +The default server address is `http://127.0.0.1:8080`. + +The server uses the reproducible `MODEL_PROVIDER=openai` path from +`github.com/cloudwego/eino-ext/components/model/openai`. Prototype-only provider +and tool dependencies from the standalone source were removed for this monorepo +example. The read-only `file_read` tool is implemented locally in +`internal/agent/tools.go`. + +## Environment + +| Variable | Default | Description | +| --- | --- | --- | +| `HOST` | `127.0.0.1` | Server bind host. | +| `PORT` | `8080` | Server port. | +| `MODEL_PROVIDER` | `openai` | Only `openai` is supported in this monorepo example. | +| `MODEL` | `gpt-4o` | Model passed to the OpenAI Eino provider. | +| `OPENAI_API_KEY` | unset | Required for model-backed routes and multimodal helper clients. | +| `AGENT_WORKSPACE` | process working directory | Read-only root for the `file_read` tool. Set it deliberately. | +| `AGENT_AUTO_APPROVE` | `false` | Bypass the approval interrupt path for `/agentic`. | +| `AGENT_MAX_ITERATIONS` | `8` | Model/tool loop iteration budget. | +| `CORS_ENABLED` | `true` | Permissive CORS for local UI development. | +| `AGENTIC_UI_PACE_MS` | `600` | Step delay for `/agentic_generative_ui`. | + +## Routes + +| Route | Notes | +| --- | --- | +| `GET /` | Health/config metadata and route list. | +| `POST /agentic` | Primary read-only agent loop. | +| `POST /agentic_chat` | Dojo frontend-tools chat route. | +| `POST /backend_tool_rendering` | Dojo route alias using the frontend-tools chat posture. | +| `POST /human_in_the_loop` | Approval route. `X-AG-Approval: off` or `?approval=off` disables the approval gate. | +| `POST /agentic_generative_ui` | Deterministic multi-step generative UI route. | +| `POST /tool_based_generative_ui` | Client-tool generative UI route. | +| `POST /shared_state` | Recipe shared-state route. | +| `POST /predictive_state_updates` | Document predictive state updates route. | +| `POST /agentic_chat_multimodal` | Multimodal-capable chat route using the OpenAI provider path. | +| `POST /image-gen` | OpenAI image generation helper. | +| `POST /vision` | OpenAI vision helper. | +| `POST /audio` | OpenAI audio transcription helper. | +| `POST /document` | OpenAI document analysis helper. | + +All `POST` routes accept AG-UI `RunAgentInput` JSON unless noted by their +specific handler. SSE responses use the AG-UI event stream format. + +## Dojo + +Run this server separately and configure Dojo to point the relevant integration +runtime URL at `http://127.0.0.1:8080`. This server lives under the Go SDK +examples rather than an `integrations/.../examples` package, so it is not +automatically registered as a first-class Dojo integration by this change. + +## Go Client + +The Go example client default endpoint is aligned with this server: + +```text +http://localhost:8080/agentic +``` + +Pass an explicit endpoint to the client if you run the server on a different +host or port. + +## Verification + +```bash +cd sdks/community/go/example/server +gofmt -w ./cmd ./internal +go mod tidy +go test ./... +go test ./... -run 'Test.*Route|Test.*App|Test.*SSE' +rg -n 'github.com/mattsp1290/ag-ui-go-server-example|/Users/punk1290/git|v0.0.0-00010101000000-000000000000' go.mod go.sum cmd internal +``` + +Credentialed model calls require `OPENAI_API_KEY`; integration tests that need +it skip when the variable is unset. diff --git a/sdks/community/go/example/server/cmd/main.go b/sdks/community/go/example/server/cmd/main.go index ae61d66d1e..8a72e251cf 100644 --- a/sdks/community/go/example/server/cmd/main.go +++ b/sdks/community/go/example/server/cmd/main.go @@ -1,175 +1,302 @@ +// Command server is a Fiber v3 implementation of the AG-UI protocol backed by an +// eino agent loop. The agent streams the full AG-UI event surface and can read +// (never write) files via a single read-only file_read tool. package main import ( + "bufio" "context" - "errors" + "encoding/json" "fmt" + "log/slog" + "net" "os" "os/signal" + "strconv" "syscall" "time" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/mcp" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/routes" + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/cors" "github.com/gofiber/fiber/v3/middleware/requestid" - "github.com/sirupsen/logrus" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/agent" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/audio" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/document" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/imagegen" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/vision" ) -func newErrorHandler() fiber.ErrorHandler { - return func(c fiber.Ctx, err error) error { - code := fiber.StatusInternalServerError - var ferr *fiber.Error - if errors.As(err, &ferr) { - code = ferr.Code - } +func main() { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + cfg := config.Load() + ctx := context.Background() - entry := logrus.NewEntry(logrus.StandardLogger()) - entry.WithFields(logrus.Fields{ - "error": err.Error(), - "status": code, - }).Error("Request error") + if cfg.MaxIterations <= 0 { + logger.Warn("AGENT_MAX_ITERATIONS is <= 0; using the default per-run iteration budget", + "configured", cfg.MaxIterations, "default", config.DefaultMaxIterations) + } - return c.Status(code).JSON(fiber.Map{ - "error": true, - "message": err.Error(), - }) + if os.Getenv("AGENT_WORKSPACE") == "" { + logger.Warn("AGENT_WORKSPACE is unset; the read-only file_read tool is rooted at the "+ + "process working directory — set it deliberately to bound what the agent can read", + "workspace", cfg.Workspace) } -} -func registerRoutes(app *fiber.App, cfg *config.Config) { + base, err := agent.NewModel(ctx, cfg) + if err != nil { + logger.Error("failed to construct chat model", "provider", cfg.Provider, "error", err) + fmt.Fprintln(os.Stderr, "\nHint: set OPENAI_API_KEY or use a supported MODEL_PROVIDER.") + os.Exit(1) + } - // Basic info route - app.Get("/", func(c fiber.Ctx) error { - return c.JSON(fiber.Map{ - "message": "AG-UI Go Example Server is running!", - "path": c.Path(), - "method": c.Method(), - "headers": c.GetReqHeaders(), - }) - }) + tools, err := agent.NewReadOnlyToolset(cfg.Workspace) + if err != nil { + logger.Error("failed to build toolset", "workspace", cfg.Workspace, "error", err) + os.Exit(1) + } - if !cfg.EnableSSE { - return + // boundModel is shared across all concurrent /agentic requests. This is safe + // because eino's ToolCallingChatModel.Stream is stateless per call (it takes + // the messages as an argument and returns a fresh StreamReader). A future + // provider that caches per-instance state would need a per-request clone or pool. + boundModel, err := base.WithTools(tools.Infos()) + if err != nil { + logger.Error("failed to bind tools", "error", err) + os.Exit(1) } - // Feature routes - app.Post("/agentic", routes.AgenticHandler(cfg)) -} + deps := &agent.Deps{ + Model: boundModel, + BaseModel: base, + Tools: tools, + Store: runstore.New(), + AutoApprove: cfg.AutoApprove, + MaxIterations: cfg.MaxIterations, + Logger: logger, + Provider: cfg.Provider, + } -func logConfig(logger *logrus.Logger, cfg *config.Config) { - logger.WithFields(logrus.Fields{ - "host": cfg.Host, - "port": cfg.Port, - "log_level": cfg.LogLevel, - "enable_sse": cfg.EnableSSE, - "read_timeout": cfg.ReadTimeout, - "write_timeout": cfg.WriteTimeout, - "sse_keepalive": cfg.SSEKeepAlive, - "cors_enabled": cfg.CORSEnabled, - "streaming_chunk_delay": cfg.StreamingChunkDelay, - }).Info("Server configuration loaded") -} + // Graceful shutdown: on SIGINT/SIGTERM, Fiber stops accepting connections and + // lets in-flight SSE runs drain (up to ShutdownTimeout) instead of being killed. + // sigCtx is also threaded into the handlers so a shutdown cancels the run + // context of in-flight requests, aborting model streams promptly. + sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() -func createApp(cfg *config.Config, logger *logrus.Logger) *fiber.App { - app := fiber.New(fiber.Config{ - AppName: "AG-UI Example Server", - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - ErrorHandler: newErrorHandler(), - }) + app := newApp(sigCtx, cfg, deps, logger) - // Middleware - app.Use(requestid.New()) + addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) + logger.Info("starting server", "addr", addr, "provider", cfg.Provider, "model", cfg.Model, + "workspace", cfg.Workspace, "autoApprove", cfg.AutoApprove) + if err := app.Listen(addr, fiber.ListenConfig{ + GracefulContext: sigCtx, + ShutdownTimeout: 10 * time.Second, + }); err != nil { + logger.Error("server stopped", "error", err) + os.Exit(1) + } +} - // CORS - if cfg.CORSEnabled { +func newApp(shutdownCtx context.Context, cfg config.Config, deps *agent.Deps, logger *slog.Logger) *fiber.App { + app := fiber.New(fiber.Config{AppName: "ag-ui-go-server-example", BodyLimit: 20 * 1024 * 1024}) + app.Use(requestid.New()) + if cfg.CORS { app.Use(cors.New(cors.Config{ - AllowOrigins: cfg.CORSAllowedOrigins, - AllowMethods: []string{"GET", "POST", "HEAD", "PUT", "DELETE", "PATCH", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"}, - AllowCredentials: false, + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Cache-Control"}, })) } - // Content negotiation - //app.Use(encoding.ContentNegotiationMiddleware(encoding.ContentNegotiationConfig{ - // DefaultContentType: "application/json", - // SupportedTypes: []string{"application/json", "application/vnd.ag-ui+json"}, - // EnableLogging: cfg.LogLevel == "debug", - //})) + app.Get("/", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "ag-ui-go-server-example is running", + "provider": cfg.Provider, + "model": cfg.Model, + "workspace": cfg.Workspace, + "autoApprove": cfg.AutoApprove, + "routes": []string{ + "/agentic", + "/agentic_chat", + "/backend_tool_rendering", + "/human_in_the_loop", + "/agentic_generative_ui", + "/tool_based_generative_ui", + "/shared_state", + "/predictive_state_updates", + "/agentic_chat_multimodal", + "/image-gen", + "/vision", + "/audio", + "/document", + }, + }) + }) + + app.Post("/agentic", agenticHandler(shutdownCtx, deps, logger)) + app.Post("/image-gen", imagegen.Handler(shutdownCtx, logger)) + app.Post("/vision", vision.Handler(shutdownCtx, logger)) + app.Post("/audio", audio.Handler(shutdownCtx, logger)) + app.Post("/document", document.Handler(shutdownCtx, logger)) - // Routes - registerRoutes(app, cfg) + // Dojo feature-parity routes. The path strings are a fixed contract the Dart + // SDK binds to. Each supplies only its run function to the shared streamHandler. + app.Post("/agentic_generative_ui", streamHandler(shutdownCtx, logger, "agentic_generative_ui", + agent.AgenticGenerativeUI{Pace: cfg.GenUIPace}.Run)) + app.Post("/agentic_chat", streamHandler(shutdownCtx, logger, "agentic_chat", + func(ctx context.Context, emit *agent.Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + agent.Run(ctx, emit, in, deps, agent.AgenticChatConfig(), threadID, runID) + })) + app.Post("/backend_tool_rendering", streamHandler(shutdownCtx, logger, "backend_tool_rendering", + func(ctx context.Context, emit *agent.Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + agent.Run(ctx, emit, in, deps, agent.AgenticChatConfig(), threadID, runID) + })) + app.Post("/tool_based_generative_ui", streamHandler(shutdownCtx, logger, "tool_based_generative_ui", + func(ctx context.Context, emit *agent.Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + agent.Run(ctx, emit, in, deps, agent.ToolBasedGenerativeUIConfig(), threadID, runID) + })) + app.Post("/shared_state", streamHandler(shutdownCtx, logger, "shared_state", + agent.SharedState{Deps: deps}.Run)) + app.Post("/predictive_state_updates", streamHandler(shutdownCtx, logger, "predictive_state_updates", + agent.PredictiveState{Deps: deps}.Run)) + app.Post("/agentic_chat_multimodal", streamHandler(shutdownCtx, logger, "agentic_chat_multimodal", + func(ctx context.Context, emit *agent.Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + agent.Run(ctx, emit, in, deps, agent.AgenticChatConfig(), threadID, runID) + })) + // /human_in_the_loop reads its per-request approval toggle from the request + // (header/query) before streaming, so it registers a thin handler that resolves + // the config and delegates to the shared streamRun. + hilSW := sse.NewSSEWriter().WithLogger(logger) + app.Post("/human_in_the_loop", func(c fiber.Ctx) error { + cfg := agent.HumanInTheLoopConfig(approvalMode(c)) + return streamRun(c, shutdownCtx, logger, hilSW, "human_in_the_loop", + func(ctx context.Context, emit *agent.Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + agent.Run(ctx, emit, in, deps, cfg, threadID, runID) + }) + }) return app } -func main() { - // Load configuration with proper precedence: flags > env > defaults - cfg, err := config.LoadConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) - os.Exit(1) +// streamHandler is the shared SSE-handler boilerplate for the feature routes: +// it parses the RunAgentInput, defaults the thread/run IDs, sets the SSE headers, +// wires an Emitter onto a shutdown-derived run context, recovers a panic into a +// RUN_ERROR, and invokes the route's run function. Each route supplies only `run`. +func streamHandler(shutdownCtx context.Context, logger *slog.Logger, name string, + run func(ctx context.Context, emit *agent.Emitter, in *aguitypes.RunAgentInput, threadID, runID string)) fiber.Handler { + sw := sse.NewSSEWriter().WithLogger(logger) + return func(c fiber.Ctx) error { + return streamRun(c, shutdownCtx, logger, sw, name, run) } +} - // Set up structured logging with logrus - logger := logrus.New() - - // Log the effective configuration - logConfig(logger, cfg) +// streamRun is the inner SSE-handler body, shared by streamHandler and routes that +// must read request metadata (e.g. /human_in_the_loop reads its approval header) +// before the stream writer runs — the fiber.Ctx is recycled once the handler +// returns, so any header/query read must happen synchronously here, not inside the +// stream-writer closure. +func streamRun(c fiber.Ctx, shutdownCtx context.Context, logger *slog.Logger, sw *sse.SSEWriter, name string, + run func(ctx context.Context, emit *agent.Emitter, in *aguitypes.RunAgentInput, threadID, runID string)) error { + var in aguitypes.RunAgentInput + if err := json.Unmarshal(c.Body(), &in); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } - app := createApp(cfg, logger) + threadID := in.ThreadID + if threadID == "" { + threadID = aguievents.GenerateThreadID() + } + runID := in.RunID + if runID == "" { + runID = aguievents.GenerateRunID() + } - // Start server in a goroutine - serverAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") - go func() { - logger.WithField("address", serverAddr).Info("Starting server") - if err := app.Listen(serverAddr); err != nil { - logger.WithError(err).Error("Server failed to start") - os.Exit(1) + return c.SendStreamWriter(func(w *bufio.Writer) { + runCtx, cancel := context.WithCancel(shutdownCtx) + defer cancel() + emit := agent.NewEmitter(runCtx, w, sw, threadID, runID, cancel) + defer func() { + if r := recover(); r != nil { + logger.Error("handler panicked", "route", name, "thread", threadID, "run", runID, "panic", r) + emit.RunError("the agent crashed while handling this run") + } + }() + run(runCtx, emit, &in, threadID, runID) + if err := emit.Err(); err != nil { + logger.Warn("event stream ended early", "route", name, "thread", threadID, "run", runID, "error", err) } - }() - - logger.WithField("address", serverAddr).Info("Server started successfully") + }) +} - // Start mcp in a goroutine - mcpServer, err := mcp.NewServer(mcp.DefaultPort) - if err != nil { - logger.WithError(err).Error("Failed to create MCP server") - os.Exit(1) +// approvalMode reads the per-request approval toggle for /human_in_the_loop from +// the X-AG-Approval header, falling back to the ?approval= query param. The demo +// always sends forwardedProps:{}, so a header/query channel needs no demo-side +// send change. Empty (the default) keeps the approval gate on. +func approvalMode(c fiber.Ctx) string { + if v := c.Get("X-AG-Approval"); v != "" { + return v } - go func() { - err := mcpServer.Start() - if err != nil { - logger.WithError(err).Error("MCP server failed to start") - os.Exit(1) - } - }() - - // Wait for interrupt signal to gracefully shutdown the server - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit + return c.Query("approval") +} - logger.Info("Shutting down server...") +func agenticHandler(shutdownCtx context.Context, deps *agent.Deps, logger *slog.Logger) fiber.Handler { + sw := sse.NewSSEWriter().WithLogger(logger) + return func(c fiber.Ctx) error { + var in aguitypes.RunAgentInput + if err := json.Unmarshal(c.Body(), &in); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } - // Graceful shutdown with timeout - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + threadID := in.ThreadID + if threadID == "" { + threadID = aguievents.GenerateThreadID() + } + runID := in.RunID + if runID == "" { + runID = aguievents.GenerateRunID() + } - err = mcpServer.Shutdown(ctx) - if err != nil { - logger.WithError(err).Error("MCP server shutdown error") - } + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + // CORS headers are owned by the cors middleware (gated by CORS_ENABLED), so + // it stays the single source of truth. Setting Access-Control-Allow-Origin + // here would duplicate it and force "*" even when CORS is disabled. - if err = app.ShutdownWithContext(ctx); err != nil { - logger.WithError(err).Error("Server shutdown error") - os.Exit(1) + return c.SendStreamWriter(func(w *bufio.Writer) { + // Derive the run context from the shutdown context, not the fasthttp + // RequestCtx: the stream writer runs after the handler returns (RequestCtx + // is then recycled) and RequestCtx never signals client disconnect. + // Parenting on shutdownCtx means a SIGINT/SIGTERM also cancels in-flight + // runs, aborting the model stream instead of leaking it until the drain + // deadline. The emitter additionally cancels this context on the first + // failed write, aborting the stream when the client goes away. + runCtx, cancel := context.WithCancel(shutdownCtx) + defer cancel() + emit := agent.NewEmitter(runCtx, w, sw, threadID, runID, cancel) + // A panic inside the agent loop (provider bug, nil deref) would otherwise + // unwind through the stream-writer goroutine and truncate the stream with + // no terminal event. Turn it into a RUN_ERROR instead. + defer func() { + if r := recover(); r != nil { + logger.Error("agent panicked", "thread", threadID, "run", runID, "panic", r) + emit.RunError("the agent crashed while handling this run") + } + }() + agent.Run(runCtx, emit, &in, deps, agent.DefaultRunConfig(), threadID, runID) + if err := emit.Err(); err != nil { + logger.Warn("event stream ended early", "thread", threadID, "run", runID, "error", err) + } + }) } - - logger.Info("Server shutdown complete") } diff --git a/sdks/community/go/example/server/cmd/main_test.go b/sdks/community/go/example/server/cmd/main_test.go new file mode 100644 index 0000000000..4b50037cec --- /dev/null +++ b/sdks/community/go/example/server/cmd/main_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "io" + "log/slog" + "net/http" + "strings" + "testing" + "time" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" + "github.com/gofiber/fiber/v3" +) + +func testApp() *fiber.App { + return newApp(context.Background(), config.Config{ + Host: "127.0.0.1", + Port: 8080, + Provider: "openai", + Model: "test-model", + Workspace: ".", + CORS: true, + GenUIPace: time.Millisecond, + }, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +func TestAppHealthRouteNoCredentials(t *testing.T) { + resp, err := testApp().Test(newRequest(t, http.MethodGet, "/", "")) + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET / status = %d, body = %s", resp.StatusCode, body) + } + for _, want := range []string{"ag-ui-go-server-example is running", "/agentic_chat", "/backend_tool_rendering", "/agentic_chat_multimodal"} { + if !strings.Contains(string(body), want) { + t.Fatalf("GET / body missing %q: %s", want, body) + } + } +} + +func TestAppRouteRegistrationMalformedJSON(t *testing.T) { + for _, route := range []string{ + "/agentic", + "/agentic_chat", + "/backend_tool_rendering", + "/human_in_the_loop", + "/agentic_generative_ui", + "/tool_based_generative_ui", + "/shared_state", + "/predictive_state_updates", + "/agentic_chat_multimodal", + "/image-gen", + "/vision", + "/audio", + "/document", + } { + t.Run(route, func(t *testing.T) { + resp, err := testApp().Test(newRequest(t, http.MethodPost, route, "{")) + if err != nil { + t.Fatalf("POST %s: %v", route, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + t.Fatalf("POST %s returned 404", route) + } + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("POST %s status = %d, want 400; body = %s", route, resp.StatusCode, body) + } + }) + } +} + +func TestAppValidSSELifecycleNoCredentials(t *testing.T) { + body := `{"threadId":"t","runId":"r","messages":[{"id":"u1","role":"user","content":"Plan dinner"}],"tools":[],"context":[],"forwardedProps":{},"state":{}}` + resp, err := testApp().Test(newRequest(t, http.MethodPost, "/agentic_generative_ui", body)) + if err != nil { + t.Fatalf("POST /agentic_generative_ui: %v", err) + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, out) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream", ct) + } + for _, want := range []string{`"type":"RUN_STARTED"`, `"type":"RUN_FINISHED"`} { + if !strings.Contains(string(out), want) { + t.Fatalf("SSE body missing %q: %s", want, out) + } + } +} + +func newRequest(t *testing.T, method, path, body string) *http.Request { + t.Helper() + req, err := http.NewRequest(method, path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + return req +} diff --git a/sdks/community/go/example/server/go.mod b/sdks/community/go/example/server/go.mod index 8d1393d427..84a425098c 100644 --- a/sdks/community/go/example/server/go.mod +++ b/sdks/community/go/example/server/go.mod @@ -1,68 +1,61 @@ module github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server -go 1.24.4 +go 1.25.0 replace github.com/ag-ui-protocol/ag-ui/sdks/community/go => ../../ require ( github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260305052815-7d95cd7654bc - github.com/gofiber/fiber/v3 v3.0.0-beta.5 - github.com/i2y/langchaingo-mcp-adapter v0.0.0-20250623114610-a01671e1c8df - github.com/mark3labs/mcp-go v0.32.0 - github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.10.0 - github.com/tmc/langchaingo v0.1.13 - golang.org/x/sync v0.16.0 + github.com/cloudwego/eino v0.9.2 + github.com/cloudwego/eino-ext/components/model/openai v0.1.13 + github.com/eino-contrib/jsonschema v1.0.3 + github.com/evanphx/json-patch v0.5.2 + github.com/gofiber/fiber/v3 v3.3.0 ) require ( - github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.2.0 // indirect - github.com/Masterminds/sprig/v3 v3.2.3 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dlclark/regexp2 v1.10.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/gofiber/schema v1.6.0 // indirect - github.com/gofiber/utils/v2 v2.0.0-beta.13 // indirect - github.com/google/go-cmp v0.7.0 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/goph/emperror v0.17.2 // indirect - github.com/huandu/xstrings v1.3.3 // indirect - github.com/imdario/mergo v0.3.13 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mitchellh/copystructure v1.0.0 // indirect - github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkoukk/tiktoken-go v0.1.6 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect - github.com/shopspring/decimal v1.2.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/tinylib/msgp v1.3.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.64.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yargevad/filepathx v1.0.0 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect - go.starlark.net v0.0.0-20230302034142-4b1e35fe2254 // indirect - golang.org/x/crypto v0.40.0 // indirect + golang.org/x/arch v0.11.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/sdks/community/go/example/server/go.sum b/sdks/community/go/example/server/go.sum index b1f147243b..68e93067c1 100644 --- a/sdks/community/go/example/server/go.sum +++ b/sdks/community/go/example/server/go.sum @@ -1,141 +1,74 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.114.0 h1:OIPFAdfrFDFO2ve2U7r/H5SwSbBzEdrBdE7xkgwc+kY= -cloud.google.com/go v0.114.0/go.mod h1:ZV9La5YYxctro1HTPug5lXH/GefROyW8PPD4T8n9J8E= -cloud.google.com/go/ai v0.7.0 h1:P6+b5p4gXlza5E+u7uvcgYlzZ7103ACg70YdZeC6oGE= -cloud.google.com/go/ai v0.7.0/go.mod h1:7ozuEcraovh4ABsPbrec3o4LmFl9HigNI3D5haxYeQo= -cloud.google.com/go/aiplatform v1.68.0 h1:EPPqgHDJpBZKRvv+OsB3cr0jYz3EL2pZ+802rBPcG8U= -cloud.google.com/go/aiplatform v1.68.0/go.mod h1:105MFA3svHjC3Oazl7yjXAmIR89LKhRAeNdnDKJczME= -cloud.google.com/go/auth v0.5.1 h1:0QNO7VThG54LUzKiQxv8C6x1YX7lUrzlAa1nVLF8CIw= -cloud.google.com/go/auth v0.5.1/go.mod h1:vbZT8GjzDf3AVqCcQmqeeM32U9HBFc32vVVAbwDsa6s= -cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= -cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= -cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -cloud.google.com/go/iam v1.1.8 h1:r7umDwhj+BQyz0ScZMp4QrGXjSTI3ZINnpgU2nlB/K0= -cloud.google.com/go/iam v1.1.8/go.mod h1:GvE6lyMmfxXauzNq8NbgJbeVQNspG+tcdL/W8QO1+zE= -cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= -cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= -cloud.google.com/go/vertexai v0.12.0 h1:zTadEo/CtsoyRXNx3uGCncoWAP1H2HakGqwznt+iMo8= -cloud.google.com/go/vertexai v0.12.0/go.mod h1:8u+d0TsvBfAAd2x5R6GMgbYhsLgo3J7lmP4bR8g2ig8= -github.com/AssemblyAI/assemblyai-go-sdk v1.3.0 h1:AtOVgGxUycvK4P4ypP+1ZupecvFgnfH+Jsum0o5ILoU= -github.com/AssemblyAI/assemblyai-go-sdk v1.3.0/go.mod h1:H0naZbvpIW49cDA5ZZ/gggeXqi7ojSGB1mqshRk6kNE= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= -github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= -github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= -github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= -github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= -github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260305052815-7d95cd7654bc h1:H+mcUhWlO1xMOjm1kvsZjFJYmWDtlPSyvWhLIrX7ITU= -github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260305052815-7d95cd7654bc/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= -github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= +github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/eino v0.9.2 h1:q9nsOy79UAs2yiCpVLzEzIOyv1BWbiP1rrdmNcv1wf0= +github.com/cloudwego/eino v0.9.2/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= +github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM= +github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= -github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= -github.com/getzep/zep-go v1.0.4 h1:09o26bPP2RAPKFjWuVWwUWLbtFDF/S8bfbilxzeZAAg= -github.com/getzep/zep-go v1.0.4/go.mod h1:HC1Gz7oiyrzOTvzeKC4dQKUiUy87zpIJl0ZFXXdHuss= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= -github.com/gofiber/fiber/v3 v3.0.0-beta.5 h1:MSGbiQZEYiYOqti2Ip2zMRkN4VvZw7Vo7dwZBa1Qjk8= -github.com/gofiber/fiber/v3 v3.0.0-beta.5/go.mod h1:XmI2Agulde26YcQrA2n8X499I1p98/zfCNbNObVUeP8= -github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= -github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= -github.com/gofiber/utils/v2 v2.0.0-beta.13 h1:dlpbGFLveQ9OduL2UHw4dtu4lXE+Gb3bHMc+8Yxp/dk= -github.com/gofiber/utils/v2 v2.0.0-beta.13/go.mod h1:qEZ175nSOkl5xciHmqxwNDsWzwiB39gB8RgU1d3U4mQ= +github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8= +github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs= +github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/generative-ai-go v0.15.1 h1:n8aQUpvhPOlGVuM2DRkJ2jvx04zpp42B778AROJa+pQ= -github.com/google/generative-ai-go v0.15.1/go.mod h1:AAucpWZjXsDKhQYWvCYuP6d0yB1kX998pJlOW1rAesw= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= -github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= -github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= -github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= -github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/i2y/langchaingo-mcp-adapter v0.0.0-20250623114610-a01671e1c8df h1:4lTJXCZw16BF0BCzrQ1LUzlMW4+2OwBkkYj1/bRybhY= -github.com/i2y/langchaingo-mcp-adapter v0.0.0-20250623114610-a01671e1c8df/go.mod h1:oL2JAtsIp/1vnVy4UG4iDzL8SZwkOzqvRL3YR9PGPjs= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -145,22 +78,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= -github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= +github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= -github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= -github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -175,51 +102,52 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw= -github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= -github.com/shamaton/msgpack/v2 v2.2.3 h1:uDOHmxQySlvlUYfQwdjxyybAOzjlQsD1Vjy+4jmO9NM= -github.com/shamaton/msgpack/v2 v2.2.3/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= +github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= -github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= -github.com/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1CaA= -github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og= -github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -228,150 +156,35 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= -github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= -github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181 h1:K+bMSIx9A7mLES1rtG+qKduLIXq40DAzYHtb0XuCukA= -gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181/go.mod h1:dzYhVIwWCtzPAa4QP98wfB9+mzt33MSmM8wsKiMi2ow= -gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82 h1:oYrL81N608MLZhma3ruL8qTM4xcpYECGut8KSxRY59g= -gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82/go.mod h1:Gn+LZmCrhPECMD3SOKlE+BOHwhOYD9j7WT9NUtkCrC8= -gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a h1:O85GKETcmnCNAfv4Aym9tepU8OE0NmcZNqPlXcsBKBs= -gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a/go.mod h1:LaSIs30YPGs1H5jwGgPhLzc8vkNc/k0rDX/fEZqiU/M= -gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84 h1:qqjvoVXdWIcZCLPMlzgA7P9FZWdPGPvP/l3ef8GzV6o= -gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84/go.mod h1:IJZ+fdMvbW2qW6htJx7sLJ04FEs4Ldl/MDsJtMKywfw= -gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f h1:Wku8eEdeJqIOFHtrfkYUByc4bCaTeA6fL0UJgfEiFMI= -gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f/go.mod h1:Tiuhl+njh/JIg0uS/sOJVYi0x2HEa5rc1OAaVsb5tAs= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.starlark.net v0.0.0-20230302034142-4b1e35fe2254 h1:Ss6D3hLXTM0KobyBYEAygXzFfGcjnmfEJOBgSbemCtg= -go.starlark.net v0.0.0-20230302034142-4b1e35fe2254/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= +golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.183.0 h1:PNMeRDwo1pJdgNcFQ9GstuLe/noWKIc89pRWRLMvLwE= -google.golang.org/api v0.183.0/go.mod h1:q43adC5/pHoSZTx5h2mSmdF7NcyfW9JuDyIOJAgS9ZQ= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240528184218-531527333157 h1:u7WMYrIrVvs0TF5yaKwKNbcJyySYf+HAIFXxWltJOXE= -google.golang.org/genproto v0.0.0-20240528184218-531527333157/go.mod h1:ubQlAQnzejB8uZzszhrTCU2Fyp6Vi7ZE5nn0c3W8+qQ= -google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= -google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= -nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= -sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/sdks/community/go/example/server/internal/agent/agenticui.go b/sdks/community/go/example/server/internal/agent/agenticui.go new file mode 100644 index 0000000000..3a44e4c735 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/agenticui.go @@ -0,0 +1,145 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" +) + +// AgenticGenerativeUI runs the /agentic_generative_ui flow (request 06): a +// simulated long-running task reported as a live "steps" checklist the UI renders +// and advances in place. The work is simulated; the point is the streamed progress. +// +// It emits an initial STATE_SNAPSHOT carrying a steps array (all "pending"), then +// per-step STATE_DELTAs flipping "/steps/{i}/status" pending→in_progress→completed, +// paced so the progression is visible, bracketed by STEP_STARTED/FINISHED and a +// concluding text summary. The state shape ({"steps":[{description,status}]}) and +// the "/steps/{i}/status" delta path match what the demo already renders. +type AgenticGenerativeUI struct { + // Pace is the delay between status transitions. 0 means no delay (tests); + // the server wires a visible delay so the checklist animates. + Pace time.Duration +} + +// Run executes the flow. It is self-contained — it does not use the model +// tool-loop. ctx cancellation (client disconnect / shutdown) aborts promptly. +func (a AgenticGenerativeUI) Run(ctx context.Context, emit *Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + emit.RunStarted() + + descrs := planSteps(in) + doc := NewDocState(map[string]any{"steps": stepsToState(descrs)}) + + // Plan: publish the steps checklist, all pending. + emit.StepStarted("plan") + emit.StateSnapshot(doc.Snapshot()) + emit.StepFinished("plan") + + // Execute: advance each step, paced so the UI animates. + emit.StepStarted("execute") + for i := range descrs { + if ctx.Err() != nil || emit.Err() != nil { + return // client gone / shutting down — stop emitting + } + if !a.advance(ctx, emit, doc, i, "in_progress") { + return + } + a.sleep(ctx) + if ctx.Err() != nil || emit.Err() != nil { + return + } + if !a.advance(ctx, emit, doc, i, "completed") { + return + } + a.sleep(ctx) + } + emit.StepFinished("execute") + + // Concluding summary. + msgID := events.GenerateMessageID() + emit.TextStart(msgID) + emit.TextContent(msgID, "All steps complete.") + emit.TextEnd(msgID) + + emit.MessagesSnapshot([]aguitypes.Message{ + {ID: msgID, Role: aguitypes.RoleAssistant, Content: "All steps complete."}, + }) + emit.RunFinishedSuccess() +} + +// advance flips one step's status, keeping DocState consistent and emitting the +// STATE_DELTA. Returns false (after a RUN_ERROR) if the patch fails to apply. +func (a AgenticGenerativeUI) advance(ctx context.Context, emit *Emitter, doc *DocState, i int, status string) bool { + ops := []events.JSONPatchOperation{ + {Op: "replace", Path: fmt.Sprintf("/steps/%d/status", i), Value: status}, + } + if err := doc.Apply(ops); err != nil { + emit.RunError("failed to advance step state") + return false + } + emit.StateDelta(ops) + return true +} + +func (a AgenticGenerativeUI) sleep(ctx context.Context) { + if a.Pace <= 0 { + return + } + t := time.NewTimer(a.Pace) + defer t.Stop() + select { + case <-t.C: + case <-ctx.Done(): + } +} + +// planSteps derives the task plan. The request allows canned content; we keep a +// small, generic, demoable plan and fold the user's ask into the opening step so +// the checklist feels responsive to the prompt. +func planSteps(in *aguitypes.RunAgentInput) []string { + steps := []string{ + "Gather requirements", + "Research the approach", + "Draft the solution", + "Review and refine", + "Finalize and summarize", + } + if task := lastUserText(in); task != "" { + steps[0] = "Understand the request: " + truncate(task, 60) + } + return steps +} + +func stepsToState(descrs []string) []any { + steps := make([]any, len(descrs)) + for i, d := range descrs { + steps[i] = map[string]any{"description": d, "status": "pending"} + } + return steps +} + +// lastUserText returns the text of the most recent user message, if any. +func lastUserText(in *aguitypes.RunAgentInput) string { + if in == nil { + return "" + } + for i := len(in.Messages) - 1; i >= 0; i-- { + if in.Messages[i].Role == aguitypes.RoleUser { + return strings.TrimSpace(messageText(in.Messages[i])) + } + } + return "" +} + +// truncate caps a string to n runes (not bytes), so a multibyte character is never +// split into invalid UTF-8 in the step description. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return strings.TrimSpace(string(r[:n])) + "…" +} diff --git a/sdks/community/go/example/server/internal/agent/agenticui_test.go b/sdks/community/go/example/server/internal/agent/agenticui_test.go new file mode 100644 index 0000000000..2a36e57626 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/agenticui_test.go @@ -0,0 +1,111 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "strings" + "testing" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" +) + +// runAgenticUI drives AgenticGenerativeUI.Run with an instant pace and returns the +// raw SSE the client would receive. +func runAgenticUI(t *testing.T, in *aguitypes.RunAgentInput) string { + t.Helper() + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + AgenticGenerativeUI{Pace: 0}.Run(context.Background(), emit, in, in.ThreadID, in.RunID) + _ = w.Flush() + return buf.String() +} + +func TestAgenticGenerativeUI_Sequence(t *testing.T) { + in := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r", Messages: []aguitypes.Message{ + {ID: "u1", Role: aguitypes.RoleUser, Content: "Plan a trip"}, + }} + out := runAgenticUI(t, in) + + // Lifecycle + steps brackets. + for _, want := range []string{ + `"type":"RUN_STARTED"`, + `"type":"STEP_STARTED"`, + `"stepName":"plan"`, + `"type":"STATE_SNAPSHOT"`, + `"stepName":"execute"`, + `"type":"STATE_DELTA"`, + `"type":"TEXT_MESSAGE_CONTENT"`, + "All steps complete.", + `"type":"RUN_FINISHED"`, + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("unexpected RUN_ERROR:\n%s", out) + } + + // Snapshot starts all pending; deltas advance step 0 through in_progress→completed. + snapIdx := strings.Index(out, `"type":"STATE_SNAPSHOT"`) + if snapIdx < 0 || !strings.Contains(out[snapIdx:snapIdx+400], `"status":"pending"`) { + t.Errorf("snapshot should carry pending steps:\n%s", out) + } + if !strings.Contains(out, `"path":"/steps/0/status"`) { + t.Errorf("expected a /steps/0/status delta:\n%s", out) + } + if !strings.Contains(out, `"value":"in_progress"`) || !strings.Contains(out, `"value":"completed"`) { + t.Errorf("expected in_progress and completed transitions:\n%s", out) + } + // The prompt-aware first step. + if !strings.Contains(out, "Understand the request") { + t.Errorf("expected prompt-aware first step:\n%s", out) + } +} + +func TestAgenticGenerativeUI_CancelStops(t *testing.T) { + in := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled before the run + emit := NewEmitter(ctx, w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + AgenticGenerativeUI{Pace: 0}.Run(ctx, emit, in, in.ThreadID, in.RunID) + _ = w.Flush() + out := buf.String() + // It may emit the initial snapshot, but must not run to completion. + if strings.Contains(out, "All steps complete.") { + t.Errorf("a cancelled run should not reach completion:\n%s", out) + } +} + +func TestDocState_ApplyReproducesState(t *testing.T) { + doc := NewDocState(map[string]any{"steps": []any{ + map[string]any{"description": "a", "status": "pending"}, + map[string]any{"description": "b", "status": "pending"}, + }}) + if err := doc.Apply(opsReplace("/steps/1/status", "completed")); err != nil { + t.Fatalf("apply: %v", err) + } + snap := doc.Snapshot() + steps, ok := snap["steps"].([]any) + if !ok || len(steps) != 2 { + t.Fatalf("bad steps: %#v", snap["steps"]) + } + if got := steps[1].(map[string]any)["status"]; got != "completed" { + t.Errorf("step 1 status = %v, want completed", got) + } + // Snapshot must be a copy — mutating it must not affect the doc. + steps[0].(map[string]any)["status"] = "mutated" + if doc.Snapshot()["steps"].([]any)[0].(map[string]any)["status"] != "pending" { + t.Errorf("Snapshot must not alias the live document") + } +} + +func opsReplace(path string, value any) []events.JSONPatchOperation { + return []events.JSONPatchOperation{{Op: "replace", Path: path, Value: value}} +} diff --git a/sdks/community/go/example/server/internal/agent/convert.go b/sdks/community/go/example/server/internal/agent/convert.go new file mode 100644 index 0000000000..1014136f9a --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/convert.go @@ -0,0 +1,221 @@ +package agent + +import ( + "log/slog" + "strings" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/cloudwego/eino/schema" +) + +// supportsVision reports whether the named provider forwards multimodal +// (image) content to the model. The OpenAI path honours UserInputMultiContent. +func supportsVision(provider string) bool { + return provider == "openai" +} + +// toEinoMessages maps the AG-UI request message history into eino messages. +// Roles eino has no use for here (reasoning/activity) are skipped. +// provider is used to gate multimodal forwarding: only the "openai" backend +// handles UserInputMultiContent; for others, non-text parts are dropped and +// only text fragments are forwarded. +func toEinoMessages(in []aguitypes.Message, provider string) []*schema.Message { + vision := supportsVision(provider) + out := make([]*schema.Message, 0, len(in)) + for _, m := range in { + switch m.Role { + case aguitypes.RoleUser: + if vision { + if msg := toEinoUserMessage(m); msg != nil { + out = append(out, msg) + } + } else if text := messageText(m); text != "" { + out = append(out, schema.UserMessage(text)) + } + case aguitypes.RoleSystem, aguitypes.RoleDeveloper: + if text := messageText(m); text != "" { + out = append(out, schema.SystemMessage(text)) + } + case aguitypes.RoleAssistant: + content, _ := m.ContentString() + out = append(out, &schema.Message{ + Role: schema.Assistant, + Content: content, + ToolCalls: toEinoToolCalls(m.ToolCalls), + }) + case aguitypes.RoleTool: + content, _ := m.ContentString() + out = append(out, schema.ToolMessage(content, m.ToolCallID)) + } + } + return out +} + +// messageText returns the message's text. ContentString applies to plain string +// content; for multimodal/structured content ([]InputContent) it returns ok=false, +// in which case we join the text fragments. Non-text fragments (image/audio/binary) +// are dropped — this agent has no vision/audio path — but the user's typed text is +// preserved instead of the whole turn being silently discarded. An empty result +// means the turn has no usable text; the caller skips it rather than inject a blank +// turn that would erase the user's actual input. +func messageText(m aguitypes.Message) string { + if s, ok := m.ContentString(); ok { + return s + } + parts, ok := m.ContentInputContents() + if !ok { + return "" + } + var b strings.Builder + for _, p := range parts { + if p.Type == aguitypes.InputContentTypeText && p.Text != "" { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(p.Text) + } + } + return b.String() +} + +// toEinoUserMessage converts a user message to an eino message, preserving +// image parts for vision-capable providers. Non-image, non-text parts +// (audio, video, document, binary) are logged and dropped. Returns nil when +// there is no usable content after filtering. +func toEinoUserMessage(m aguitypes.Message) *schema.Message { + parts, hasParts := m.ContentInputContents() + if !hasParts { + text, _ := m.ContentString() + if text == "" { + return nil + } + return schema.UserMessage(text) + } + + var textBuf strings.Builder + var multiParts []schema.MessageInputPart + hasNonText := false + + for _, p := range parts { + switch p.Type { + case aguitypes.InputContentTypeText: + if p.Text != "" { + if textBuf.Len() > 0 { + textBuf.WriteByte('\n') + } + textBuf.WriteString(p.Text) + multiParts = append(multiParts, schema.MessageInputPart{ + Type: schema.ChatMessagePartTypeText, + Text: p.Text, + }) + } + case aguitypes.InputContentTypeImage: + if part, ok := toEinoImagePart(p); ok { + multiParts = append(multiParts, part) + hasNonText = true + } + default: + slog.Warn("unsupported multimodal content type, dropping", "type", p.Type) + } + } + + if len(multiParts) == 0 { + return nil + } + if !hasNonText { + // All parts were text — use plain Content for cleanliness. + return schema.UserMessage(textBuf.String()) + } + return &schema.Message{ + Role: schema.User, + UserInputMultiContent: multiParts, + } +} + +// toEinoImagePart maps an AG-UI InputContent image fragment to an eino +// MessageInputPart. It reads Source first (the structured path from +// UserMessage.multimodal()), then falls back to the flat URL/Data fields. +func toEinoImagePart(p aguitypes.InputContent) (schema.MessageInputPart, bool) { + img := &schema.MessageInputImage{} + if p.Source != nil { + switch p.Source.Type { + case aguitypes.InputContentSourceTypeURL: + img.URL = &p.Source.Value + case aguitypes.InputContentSourceTypeData: + img.Base64Data = &p.Source.Value + img.MIMEType = p.Source.MimeType + default: + slog.Warn("unknown image source type, dropping", "source_type", p.Source.Type) + return schema.MessageInputPart{}, false + } + } else if p.URL != "" { + img.URL = &p.URL + } else if p.Data != "" { + img.Base64Data = &p.Data + img.MIMEType = p.MimeType + } else { + slog.Warn("image part has no source URL or data, dropping") + return schema.MessageInputPart{}, false + } + return schema.MessageInputPart{ + Type: schema.ChatMessagePartTypeImageURL, + Image: img, + }, true +} + +func toEinoToolCalls(tcs []aguitypes.ToolCall) []schema.ToolCall { + if len(tcs) == 0 { + return nil + } + out := make([]schema.ToolCall, 0, len(tcs)) + for _, tc := range tcs { + out = append(out, schema.ToolCall{ + ID: tc.ID, + Type: "function", + Function: schema.FunctionCall{Name: tc.Function.Name, Arguments: tc.Function.Arguments}, + }) + } + return out +} + +// toAGUIMessages converts the eino conversation into AG-UI messages for a +// MESSAGES_SNAPSHOT event, assigning fresh ids and conforming to the SDK's +// per-role validation rules. +func toAGUIMessages(msgs []*schema.Message) []aguitypes.Message { + out := make([]aguitypes.Message, 0, len(msgs)) + for _, m := range msgs { + am := aguitypes.Message{ID: aguievents.GenerateMessageID(), Content: m.Content} + switch m.Role { + case schema.System: + am.Role = aguitypes.RoleSystem + case schema.User: + am.Role = aguitypes.RoleUser + case schema.Assistant: + am.Role = aguitypes.RoleAssistant + am.ToolCalls = toAGUIToolCalls(m.ToolCalls) + case schema.Tool: + am.Role = aguitypes.RoleTool + am.ToolCallID = m.ToolCallID + default: + continue + } + out = append(out, am) + } + return out +} + +func toAGUIToolCalls(tcs []schema.ToolCall) []aguitypes.ToolCall { + if len(tcs) == 0 { + return nil + } + out := make([]aguitypes.ToolCall, 0, len(tcs)) + for _, tc := range tcs { + out = append(out, aguitypes.ToolCall{ + ID: tc.ID, + Type: aguitypes.ToolCallTypeFunction, + Function: aguitypes.FunctionCall{Name: tc.Function.Name, Arguments: tc.Function.Arguments}, + }) + } + return out +} diff --git a/sdks/community/go/example/server/internal/agent/docstate.go b/sdks/community/go/example/server/internal/agent/docstate.go new file mode 100644 index 0000000000..06fb8ab4f1 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/docstate.go @@ -0,0 +1,91 @@ +package agent + +import ( + "encoding/json" + "fmt" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + jsonpatch "github.com/evanphx/json-patch" +) + +// DocState is a minimal collaborative-document state container for the feature +// routes (agentic_generative_ui, shared_state, predictive_state_updates). +// +// Unlike the lifecycle State (internal/agent/state.go), it snapshots the raw +// document verbatim — there are NO reserved status/filesRead/toolCalls keys. The +// document is whatever shape the route owns (e.g. {"steps":[...]} or +// {"recipe":{...}}), and STATE_DELTA patches target paths inside it directly. +// +// DocState keeps the in-memory document and the emitted RFC-6902 deltas in lock +// step: Apply mutates the document by applying the same patch the caller will emit +// as a STATE_DELTA, so a later Snapshot reflects every delta and a client that +// replays snapshot+deltas reproduces the server's state. +type DocState struct { + doc map[string]any +} + +// NewDocState builds a DocState from a seed document (deep-copied so the caller's +// map is never aliased or mutated). A nil seed yields an empty document. +func NewDocState(seed map[string]any) *DocState { + return &DocState{doc: deepCopyJSONMap(seed)} +} + +// Snapshot returns a deep copy of the document for STATE_SNAPSHOT / persistence, +// so the returned value never aliases the live backing document. +func (d *DocState) Snapshot() map[string]any { + return deepCopyJSONMap(d.doc) +} + +// Apply applies an RFC-6902 patch to the document, keeping it consistent so a +// later Snapshot reflects the change. It returns an error (without mutating the +// document) if the patch is malformed or does not apply — callers surface that as +// RUN_ERROR rather than emitting a STATE_DELTA the snapshot can't reproduce. +func (d *DocState) Apply(ops []events.JSONPatchOperation) error { + if len(ops) == 0 { + return nil + } + patchJSON, err := json.Marshal(ops) + if err != nil { + return fmt.Errorf("marshal patch: %w", err) + } + patch, err := jsonpatch.DecodePatch(patchJSON) + if err != nil { + return fmt.Errorf("decode patch: %w", err) + } + docJSON, err := json.Marshal(d.doc) + if err != nil { + return fmt.Errorf("marshal document: %w", err) + } + out, err := patch.Apply(docJSON) + if err != nil { + return fmt.Errorf("apply patch: %w", err) + } + var next map[string]any + if err := json.Unmarshal(out, &next); err != nil { + return fmt.Errorf("unmarshal patched document: %w", err) + } + if next == nil { + next = map[string]any{} + } + d.doc = next + return nil +} + +// deepCopyJSONMap deep-copies an arbitrary JSON-ish map by round-tripping through +// JSON. This is the same value space the document lives in (it came from / goes to +// JSON over the wire), so it is faithful and avoids aliasing the snapshot held by +// the SSE encoder. A nil or unmarshalable input yields a fresh empty map. +func deepCopyJSONMap(m map[string]any) map[string]any { + if len(m) == 0 { + return map[string]any{} + } + b, err := json.Marshal(m) + if err != nil { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil || out == nil { + return map[string]any{} + } + return out +} diff --git a/sdks/community/go/example/server/internal/agent/emitter.go b/sdks/community/go/example/server/internal/agent/emitter.go new file mode 100644 index 0000000000..a7a76f370a --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/emitter.go @@ -0,0 +1,215 @@ +package agent + +import ( + "bufio" + "context" + "strings" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" +) + +// Emitter serializes AG-UI events to an SSE stream. It records the first write +// error and becomes a no-op afterward, so loop code can stay terse and check +// Err() at convenient points (a write error means the client disconnected). +// +// On the first write failure it invokes cancel (if set), which cancels the run +// context so an in-flight model stream aborts promptly instead of generating +// against a gone client. This is how client disconnect is detected: fasthttp's +// RequestCtx does not signal disconnect, only a failed SSE write does. +type Emitter struct { + ctx context.Context + w *bufio.Writer + sse *sse.SSEWriter + threadID string + runID string + cancel context.CancelFunc + err error // first transport (disconnect) error; once set, all writes are no-ops + encErr error // first encoding/validation error; the event was dropped but the stream stays live +} + +// NewEmitter builds an Emitter bound to a request's SSE writer. cancel may be +// nil; when non-nil it is called once, on the first write error. +func NewEmitter(ctx context.Context, w *bufio.Writer, sw *sse.SSEWriter, threadID, runID string, cancel context.CancelFunc) *Emitter { + return &Emitter{ctx: ctx, w: w, sse: sw, threadID: threadID, runID: runID, cancel: cancel} +} + +// Err returns the first transport (client-disconnect) error, if any. +func (e *Emitter) Err() error { return e.err } + +// EncErr returns the first encoding/validation error, if any. Unlike Err it does +// not gate subsequent writes: a malformed event is dropped (and logged by the SDK) +// but the stream stays alive so the run can still reach a terminal event. +func (e *Emitter) EncErr() error { return e.encErr } + +func (e *Emitter) write(ev events.Event) { + if e.err != nil { + return + } + if err := e.sse.WriteEvent(e.ctx, e.w, ev); err != nil { + if isTransportError(err) { + // The client is gone. Stop emitting and cancel the run context so an + // in-flight model stream aborts promptly. + e.err = err + if e.cancel != nil { + e.cancel() + } + return + } + // An encoding/validation failure is a content bug, not a disconnect. Record + // it for visibility and drop just this event; keep the stream open so a + // terminal RUN_ERROR/RUN_FINISHED can still be written. + if e.encErr == nil { + e.encErr = err + } + } +} + +// isTransportError reports whether a WriteEvent error came from the socket write or +// flush (client gone) rather than event encoding/frame creation (a content bug). +// The SDK does not export typed errors, so this matches its wrapper prefixes +// (pkg/encoding/sse/writer.go); keep it in sync if those strings change. +func isTransportError(err error) bool { + msg := err.Error() + return strings.Contains(msg, "SSE write failed") || strings.Contains(msg, "SSE flush failed") +} + +// --- run lifecycle --- + +func (e *Emitter) RunStarted() { e.write(events.NewRunStartedEvent(e.threadID, e.runID)) } + +func (e *Emitter) RunFinishedSuccess() { + e.write(events.NewRunFinishedEventWithOptions(e.threadID, e.runID, events.WithSuccessOutcome())) +} + +func (e *Emitter) RunFinishedInterrupt(interrupts []types.Interrupt) { + e.write(events.NewRunFinishedEventWithOptions(e.threadID, e.runID, events.WithInterruptOutcome(interrupts))) +} + +func (e *Emitter) RunError(msg string) { + e.write(events.NewRunErrorEvent(msg, events.WithRunID(e.runID))) +} + +// --- steps --- + +func (e *Emitter) StepStarted(name string) { e.write(events.NewStepStartedEvent(name)) } +func (e *Emitter) StepFinished(name string) { e.write(events.NewStepFinishedEvent(name)) } + +// --- text messages --- + +func (e *Emitter) TextStart(id string) { + e.write(events.NewTextMessageStartEvent(id, events.WithRole("assistant"))) +} + +func (e *Emitter) TextContent(id, delta string) { + if delta == "" { + return // SDK rejects empty deltas + } + e.write(events.NewTextMessageContentEvent(id, delta)) +} + +func (e *Emitter) TextEnd(id string) { e.write(events.NewTextMessageEndEvent(id)) } + +// --- reasoning --- + +func (e *Emitter) ReasoningStart(id string) { e.write(events.NewReasoningStartEvent(id)) } + +func (e *Emitter) ReasoningMessageStart(id string) { + e.write(events.NewReasoningMessageStartEvent(id, "assistant")) +} + +func (e *Emitter) ReasoningContent(id, delta string) { + if delta == "" { + return + } + e.write(events.NewReasoningMessageContentEvent(id, delta)) +} + +func (e *Emitter) ReasoningMessageEnd(id string) { e.write(events.NewReasoningMessageEndEvent(id)) } +func (e *Emitter) ReasoningEnd(id string) { e.write(events.NewReasoningEndEvent(id)) } + +// --- tool calls --- + +func (e *Emitter) ToolStart(toolCallID, name string) { + e.write(events.NewToolCallStartEvent(toolCallID, name)) +} + +func (e *Emitter) ToolArgs(toolCallID, delta string) { + if delta == "" { + return + } + e.write(events.NewToolCallArgsEvent(toolCallID, delta)) +} + +func (e *Emitter) ToolEnd(toolCallID string) { e.write(events.NewToolCallEndEvent(toolCallID)) } + +func (e *Emitter) ToolResult(messageID, toolCallID, content string) { + if content == "" { + content = "(empty)" + } + e.write(events.NewToolCallResultEvent(messageID, toolCallID, content)) +} + +// --- state --- + +func (e *Emitter) StateSnapshot(snapshot any) { + e.write(events.NewStateSnapshotEvent(snapshot)) +} + +func (e *Emitter) StateDelta(ops []events.JSONPatchOperation) { + if len(ops) == 0 { + return + } + e.write(events.NewStateDeltaEvent(ops)) +} + +func (e *Emitter) MessagesSnapshot(msgs []types.Message) { + e.write(events.NewMessagesSnapshotEvent(scrubEncryptedValues(msgs))) +} + +// scrubEncryptedValues returns the slice with EncryptedValue/EncryptedContent +// zeroed on every message. This prevents encrypted reasoning blobs from leaking +// to clients via MESSAGES_SNAPSHOT payloads. It is a no-op (returns the original +// slice unchanged) when no message carries either field, keeping the common path +// allocation-free. +func scrubEncryptedValues(msgs []types.Message) []types.Message { + needsScrub := false + for i := range msgs { + if msgs[i].EncryptedValue != "" || msgs[i].EncryptedContent != "" { + needsScrub = true + break + } + } + if !needsScrub { + return msgs + } + out := make([]types.Message, len(msgs)) + copy(out, msgs) + for i := range out { + out[i].EncryptedValue = "" + out[i].EncryptedContent = "" + } + return out +} + +// --- activity / custom --- + +func (e *Emitter) ActivitySnapshot(messageID, activityType string, content any) { + e.write(events.NewActivitySnapshotEvent(messageID, activityType, content)) +} + +func (e *Emitter) ActivityDelta(messageID, activityType string, patch []events.JSONPatchOperation) { + if len(patch) == 0 { + return + } + e.write(events.NewActivityDeltaEvent(messageID, activityType, patch)) +} + +func (e *Emitter) ReasoningEncryptedValue(subtype events.ReasoningEncryptedValueSubtype, entityID, encryptedValue string) { + e.write(events.NewReasoningEncryptedValueEvent(subtype, entityID, encryptedValue)) +} + +func (e *Emitter) Custom(name string, value any) { + e.write(events.NewCustomEvent(name, events.WithValue(value))) +} diff --git a/sdks/community/go/example/server/internal/agent/emitter_test.go b/sdks/community/go/example/server/internal/agent/emitter_test.go new file mode 100644 index 0000000000..6962cf0c08 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/emitter_test.go @@ -0,0 +1,102 @@ +package agent + +import ( + "bufio" + "context" + "errors" + "testing" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" +) + +// failWriter always fails, forcing the SDK writer's write/flush error path. +type failWriter struct{} + +func (failWriter) Write([]byte) (int, error) { return 0, errors.New("socket gone") } + +// TestScrubEncryptedValues verifies that scrubEncryptedValues zeroes cipher +// fields on messages that carry them while leaving other fields intact, and +// returns the original slice unchanged (no allocation) when there is nothing to scrub. +func TestScrubEncryptedValues(t *testing.T) { + plain := types.Message{ID: "1", Role: types.RoleUser, Content: "hello"} + withCipher := types.Message{ + ID: "2", + Role: types.RoleAssistant, + Content: "thinking…", + EncryptedValue: "secret-ev", + EncryptedContent: "secret-ec", + } + + // No cipher → original slice returned (pointer equality). + noCipher := []types.Message{plain} + if got := scrubEncryptedValues(noCipher); &got[0] != &noCipher[0] { + t.Error("expected original slice back when no scrubbing needed") + } + + // Cipher present → new slice with fields zeroed, other fields preserved. + msgs := []types.Message{plain, withCipher} + got := scrubEncryptedValues(msgs) + if got[1].EncryptedValue != "" { + t.Errorf("EncryptedValue not scrubbed: %q", got[1].EncryptedValue) + } + if got[1].EncryptedContent != "" { + t.Errorf("EncryptedContent not scrubbed: %q", got[1].EncryptedContent) + } + if got[1].Content != withCipher.Content { + t.Errorf("Content modified unexpectedly: %q", got[1].Content) + } + // Original slice must be unmodified (copy, not in-place). + if msgs[1].EncryptedValue != "secret-ev" { + t.Error("original slice was mutated") + } +} + +// TestActivityDeltaAndReasoningEncryptedValue confirm the new emitter wrappers +// produce events with the correct type fields. We use a success writer (NopWriter) +// so we are testing serialization, not transport. +func TestActivityDeltaAndReasoningEncryptedValue(t *testing.T) { + sw := sse.NewSSEWriter() + w := bufio.NewWriter(&nopWriter{}) + em := NewEmitter(context.Background(), w, sw, "t1", "r1", nil) + + // ActivityDelta — verify it does not error (validates correctly). + patch := []events.JSONPatchOperation{{Op: "replace", Path: "/content", Value: "updated"}} + em.ActivityDelta("msg-1", "tool_use", patch) + if em.EncErr() != nil { + t.Errorf("ActivityDelta encoding error: %v", em.EncErr()) + } + + // ReasoningEncryptedValue — verify typed subtype constant is accepted. + em.ReasoningEncryptedValue(events.ReasoningEncryptedValueSubtypeMessage, "msg-2", "cipher-blob") + if em.EncErr() != nil { + t.Errorf("ReasoningEncryptedValue encoding error: %v", em.EncErr()) + } +} + +// nopWriter discards all bytes (stands in for a connected SSE client). +type nopWriter struct{} + +func (*nopWriter) Write(p []byte) (int, error) { return len(p), nil } + +// TestIsTransportErrorMatchesSDKWrappers pins the SDK's write/flush error wrapper +// strings that isTransportError matches. Disconnect detection (and the run +// cancellation it drives) depends on those exact substrings; if a future SDK bump +// rewords them, this fails loudly instead of silently reclassifying a client +// disconnect as an encoding error — which would leave a gone-client run generating +// (and billing) tokens until it finishes on its own. +func TestIsTransportErrorMatchesSDKWrappers(t *testing.T) { + sw := sse.NewSSEWriter() + w := bufio.NewWriter(failWriter{}) + + // A valid event encodes fine; the failure happens at the socket write/flush. + err := sw.WriteEvent(context.Background(), w, events.NewRunStartedEvent("t", "r")) + if err == nil { + t.Fatal("expected a write/flush error from the failing writer") + } + if !isTransportError(err) { + t.Fatalf("isTransportError must classify an SDK write/flush failure as a transport error; "+ + "the SDK wrapper strings may have changed: %v", err) + } +} diff --git a/sdks/community/go/example/server/internal/agent/featureroutes_test.go b/sdks/community/go/example/server/internal/agent/featureroutes_test.go new file mode 100644 index 0000000000..8f346dd0b3 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/featureroutes_test.go @@ -0,0 +1,142 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "io" + "log/slog" + "strings" + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/schema" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +// runCfg drives one run against the scripted model with an explicit RunConfig. +func runCfg(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput, cfg RunConfig) string { + t.Helper() + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + tools, err := NewReadOnlyToolset(t.TempDir()) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + deps := &Deps{ + Model: cm, BaseModel: cm, Tools: tools, + Store: runstore.New(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + Run(context.Background(), emit, in, deps, cfg, in.ThreadID, in.RunID) + _ = w.Flush() + return buf.String() +} + +// --- 02: tool-based generative UI --- + +func TestToolBasedGenUI_MultiDeltaStream(t *testing.T) { + // render_haiku streamed as several arg fragments (the progressive-rendering case). + m := &scriptedModel{turns: [][]*schema.Message{{ + tcOpen(0, "call_h", "render_haiku"), + tcArg(0, "call_h", "render_haiku", `{"title":"Tides",`), + tcArg(0, "call_h", "render_haiku", `"lines":["a","b","c"],`), + tcArg(0, "call_h", "render_haiku", `"mood":"calm"}`), + tcArg(0, "call_h", "render_haiku", ""), // CLOSE + }}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Write a haiku about the ocean."}}, + Tools: []aguitypes.Tool{{ + Name: "render_haiku", Description: "Render a haiku as a UI card.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "title": map[string]any{"type": "string"}, + "lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "mood": map[string]any{"type": "string", "enum": []any{"calm", "joyful", "somber"}}, + }, + "required": []any{"lines"}, + }, + }}, + } + out := runCfg(t, m, in, ToolBasedGenerativeUIConfig()) + + if c := count(out, `"type":"TOOL_CALL_START"`); c != 1 { + t.Errorf("TOOL_CALL_START = %d, want 1:\n%s", c, out) + } + if c := count(out, `"type":"TOOL_CALL_ARGS"`); c < 3 { + t.Errorf("expected >=3 progressive TOOL_CALL_ARGS deltas, got %d:\n%s", c, out) + } + if c := count(out, `"type":"TOOL_CALL_END"`); c != 1 { + t.Errorf("TOOL_CALL_END = %d, want 1:\n%s", c, out) + } + if !strings.Contains(out, `"toolCallName":"render_haiku"`) || !strings.Contains(out, `"type":"RUN_FINISHED"`) { + t.Errorf("expected a clean render_haiku hand-back:\n%s", out) + } +} + +// --- 03: human-in-the-loop --- + +func TestHumanInTheLoop_ConfigToggle(t *testing.T) { + on := HumanInTheLoopConfig("") // default → gated + off := HumanInTheLoopConfig("off") // ungated + if !strings.Contains(on.SystemPrompt, "approval") { + t.Errorf("default HITL config should carry the approval-gate prompt") + } + if strings.Contains(off.SystemPrompt, "approval tool") { + t.Errorf("approval=off should fall back to the plain agentic-chat prompt") + } + // Header value case-insensitivity. + if HumanInTheLoopConfig("OFF").SystemPrompt != off.SystemPrompt { + t.Errorf("approval toggle must be case-insensitive") + } +} + +func TestHumanInTheLoop_ProposeThenReject(t *testing.T) { + // Run A: agent proposes confirm_action (streamed, handed back). + mA := &scriptedModel{turns: [][]*schema.Message{{ + tcOpen(0, "call_1", "confirm_action"), + tcArg(0, "call_1", "confirm_action", `{"summary":"Delete 3 completed todos"}`), + tcArg(0, "call_1", "confirm_action", ""), + }}} + approvalTool := aguitypes.Tool{ + Name: "confirm_action", Description: "Ask the user to approve an action before performing it.", + Parameters: map[string]any{"type": "object", + "properties": map[string]any{"summary": map[string]any{"type": "string"}}, + "required": []any{"summary"}}, + } + inA := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "rA", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Delete all completed todos."}}, + Tools: []aguitypes.Tool{approvalTool}, + } + outA := runCfg(t, mA, inA, HumanInTheLoopConfig("")) + if !strings.Contains(outA, `"toolCallName":"confirm_action"`) || !strings.Contains(outA, `"type":"RUN_FINISHED"`) { + t.Errorf("Run A should propose confirm_action and finish plainly:\n%s", outA) + } + if strings.Contains(outA, `"interrupts"`) { + t.Errorf("Run A must NOT use the interrupt path:\n%s", outA) + } + + // Run B: client returns {"approved":false}; the agent acknowledges, no action. + mB := &scriptedModel{turns: [][]*schema.Message{{textChunk("Okay, I won't delete them.")}}} + inB := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "rB", + Messages: []aguitypes.Message{ + {ID: "u1", Role: aguitypes.RoleUser, Content: "Delete all completed todos."}, + {ID: "a1", Role: aguitypes.RoleAssistant, ToolCalls: []aguitypes.ToolCall{{ + ID: "call_1", Type: aguitypes.ToolCallTypeFunction, + Function: aguitypes.FunctionCall{Name: "confirm_action", Arguments: `{"summary":"Delete 3 completed todos"}`}, + }}}, + {ID: "t1m", Role: aguitypes.RoleTool, ToolCallID: "call_1", Content: `{"approved":false}`}, + }, + Tools: []aguitypes.Tool{approvalTool}, + } + outB := runCfg(t, mB, inB, HumanInTheLoopConfig("")) + if !strings.Contains(outB, "won't delete") || !strings.Contains(outB, `"type":"RUN_FINISHED"`) { + t.Errorf("Run B reject should acknowledge without acting:\n%s", outB) + } +} diff --git a/sdks/community/go/example/server/internal/agent/fixreview_test.go b/sdks/community/go/example/server/internal/agent/fixreview_test.go new file mode 100644 index 0000000000..962199bc6b --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/fixreview_test.go @@ -0,0 +1,131 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "log/slog" + "strings" + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +// --- I2 / IMPORTANT 1: splitSteps must not corrupt legitimate step text --- + +func TestSplitSteps_PreservesLeadingDigitsAndHyphens(t *testing.T) { + got := splitSteps("2 cups flour, sifted\n- Whisk the eggs\n1. Boil water\n3-4 minutes until golden\n350F bake") + want := []string{"2 cups flour, sifted", "Whisk the eggs", "Boil water", "3-4 minutes until golden", "350F bake"} + if len(got) != len(want) { + t.Fatalf("got %d steps %#v, want %d", len(got), got, len(want)) + } + for i := range want { + if got[i].(string) != want[i] { + t.Errorf("step %d = %q, want %q", i, got[i], want[i]) + } + } +} + +// --- IMPORTANT 2: servings as a JSON float must not void the edit batch --- + +func TestSharedState_ServingsAsFloatApplies(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("c1", "apply_recipe_changes", `{"servings":4.0,"add_ingredients":[{"name":"garlic"}]}`)}, + {textChunk("Scaled to 4 and added garlic.")}, + }} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "serve 4, add garlic"}}, + State: seededRecipeState(), + } + out := runSharedState(t, m, in) + if !strings.Contains(out, `"path":"/recipe/servings"`) || !strings.Contains(out, `"value":4`) { + t.Errorf("servings:4.0 must still apply as a servings delta:\n%s", out) + } + if !strings.Contains(out, `"garlic"`) { + t.Errorf("the whole batch must not be voided by the float servings:\n%s", out) + } +} + +// --- IMPORTANT 3: /shared_state must not leak TOOL_CALL_RESULT on malformed args --- + +func TestSharedState_MalformedArgsNoToolEvents(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("c1", "apply_recipe_changes", `{"servings":`)}, // invalid JSON + {textChunk("Sorry, I mangled that — nothing changed.")}, + }} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "serve 4"}}, + State: seededRecipeState(), + } + out := runSharedState(t, m, in) + for _, banned := range []string{`"type":"TOOL_CALL_START"`, `"type":"TOOL_CALL_ARGS"`, `"type":"TOOL_CALL_END"`, `"type":"TOOL_CALL_RESULT"`} { + if strings.Contains(out, banned) { + t.Errorf("malformed args must not leak %s on /shared_state:\n%s", banned, out) + } + } + if !strings.Contains(out, `"type":"RUN_FINISHED"`) { + t.Errorf("run should still finish cleanly:\n%s", out) + } +} + +// --- I1: a streamed tool call must be terminated (END) even on a mid-stream error --- + +// errorMidStreamModel streams a tool OPEN + an arg fragment, then fails — simulating +// a provider error after TOOL_CALL_START has already gone out. +type errorMidStreamModel struct{} + +func (errorMidStreamModel) Generate(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error) { + return nil, fmt.Errorf("unused") +} +func (m errorMidStreamModel) WithTools([]*schema.ToolInfo) (model.ToolCallingChatModel, error) { + return m, nil +} +func (errorMidStreamModel) Stream(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error) { + sr, sw := schema.Pipe[*schema.Message](4) + go func() { + defer sw.Close() + sw.Send(tcOpen(0, "call_1", "confirm_booking"), nil) + sw.Send(tcArg(0, "call_1", "confirm_booking", `{"flight":`), nil) + sw.Send(nil, fmt.Errorf("provider exploded mid-call")) + }() + return sr, nil +} + +func TestStreamingTap_TerminatesToolCallOnMidStreamError(t *testing.T) { + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "book it"}}, + Tools: []aguitypes.Tool{confirmBookingTool()}, + } + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + tools, _ := NewReadOnlyToolset(t.TempDir()) + var cm errorMidStreamModel + deps := &Deps{ + Model: cm, BaseModel: cm, Tools: tools, + Store: runstore.New(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + Run(context.Background(), emit, in, deps, AgenticChatConfig(), in.ThreadID, in.RunID) + _ = w.Flush() + out := buf.String() + + if !strings.Contains(out, `"type":"TOOL_CALL_START"`) { + t.Fatalf("expected a TOOL_CALL_START before the error:\n%s", out) + } + if c := strings.Count(out, `"type":"TOOL_CALL_END"`); c != 1 { + t.Errorf("a started tool call must be terminated exactly once on mid-stream error, got %d ENDs:\n%s", c, out) + } + if !strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("a genuine provider error should surface as RUN_ERROR:\n%s", out) + } +} diff --git a/sdks/community/go/example/server/internal/agent/frontendtools_test.go b/sdks/community/go/example/server/internal/agent/frontendtools_test.go new file mode 100644 index 0000000000..fdee550057 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/frontendtools_test.go @@ -0,0 +1,195 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "io" + "log/slog" + "strings" + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/schema" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +// runChat drives one agentic_chat run (ClientTools + streaming tap) against the +// scripted model and returns the raw SSE. BaseModel is set so per-request +// WithTools binding works (scriptedModel.WithTools returns itself). +func runChat(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput) string { + t.Helper() + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + tools, err := NewReadOnlyToolset(t.TempDir()) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + deps := &Deps{ + Model: cm, + BaseModel: cm, + Tools: tools, + Store: runstore.New(), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + Run(context.Background(), emit, in, deps, AgenticChatConfig(), in.ThreadID, in.RunID) + _ = w.Flush() + return buf.String() +} + +// streaming tool-call chunks with a stable Index, mirroring the shipped provider. +func tcOpen(idx int, id, name string) *schema.Message { + i := idx + return &schema.Message{Role: schema.Assistant, ToolCalls: []schema.ToolCall{{ + Index: &i, ID: id, Type: "function", Function: schema.FunctionCall{Name: name}, + }}} +} + +func tcArg(idx int, id, name, frag string) *schema.Message { + i := idx + return &schema.Message{Role: schema.Assistant, ToolCalls: []schema.ToolCall{{ + Index: &i, ID: id, Type: "function", Function: schema.FunctionCall{Name: name, Arguments: frag}, + }}} +} + +func confirmBookingTool() aguitypes.Tool { + return aguitypes.Tool{ + Name: "confirm_booking", + Description: "Confirm a flight booking with the user before finalizing.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"flight": map[string]any{"type": "string"}}, + "required": []any{"flight"}, + }, + } +} + +func count(s, sub string) int { return strings.Count(s, sub) } + +func TestAgenticChat_ClientToolHandBack(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{{ + tcOpen(0, "call_1", "confirm_booking"), + tcArg(0, "call_1", "confirm_booking", `{"flight":`), + tcArg(0, "call_1", "confirm_booking", `"AA9"}`), + tcArg(0, "call_1", "confirm_booking", ""), // CLOSE: empty-args backfill + }}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t1", RunID: "r1", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Book the 9am flight."}}, + Tools: []aguitypes.Tool{confirmBookingTool()}, + } + out := runChat(t, m, in) + + // Streamed tool call: exactly one START, args fragments, exactly one END. + if c := count(out, `"type":"TOOL_CALL_START"`); c != 1 { + t.Errorf("TOOL_CALL_START count = %d, want 1 (no double-emit):\n%s", c, out) + } + if c := count(out, `"type":"TOOL_CALL_END"`); c != 1 { + t.Errorf("TOOL_CALL_END count = %d, want 1:\n%s", c, out) + } + if !strings.Contains(out, `"toolCallName":"confirm_booking"`) { + t.Errorf("expected confirm_booking START:\n%s", out) + } + if c := count(out, `"type":"TOOL_CALL_ARGS"`); c < 2 { + t.Errorf("expected >=2 TOOL_CALL_ARGS deltas, got %d:\n%s", c, out) + } + // Plain finish, no interrupt. + if !strings.Contains(out, `"type":"RUN_FINISHED"`) { + t.Errorf("expected RUN_FINISHED:\n%s", out) + } + if strings.Contains(out, "INTERRUPT") || strings.Contains(out, `"interrupts"`) { + t.Errorf("hand-back must be a plain RUN_FINISHED, not an interrupt:\n%s", out) + } + if strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("unexpected RUN_ERROR:\n%s", out) + } + // The assistant tool-call message is in the snapshot. + if !strings.Contains(out, `"type":"MESSAGES_SNAPSHOT"`) { + t.Errorf("expected MESSAGES_SNAPSHOT:\n%s", out) + } +} + +func TestAgenticChat_RunBContinues(t *testing.T) { + // Run B: history carries the assistant tool-call + the role:tool result; the + // model now returns a final text answer. + m := &scriptedModel{turns: [][]*schema.Message{{textChunk("Done — your 9am flight is booked.")}}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t1", RunID: "r2", + Messages: []aguitypes.Message{ + {ID: "u1", Role: aguitypes.RoleUser, Content: "Book the 9am flight."}, + {ID: "a1", Role: aguitypes.RoleAssistant, ToolCalls: []aguitypes.ToolCall{{ + ID: "call_1", Type: aguitypes.ToolCallTypeFunction, + Function: aguitypes.FunctionCall{Name: "confirm_booking", Arguments: `{"flight":"AA9"}`}, + }}}, + {ID: "t1m", Role: aguitypes.RoleTool, ToolCallID: "call_1", Content: `{"confirmed":true}`}, + }, + Tools: []aguitypes.Tool{confirmBookingTool()}, + } + out := runChat(t, m, in) + if !strings.Contains(out, "your 9am flight is booked") { + t.Errorf("expected the continued text answer:\n%s", out) + } + if !strings.Contains(out, `"type":"RUN_FINISHED"`) || strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("expected clean RUN_FINISHED:\n%s", out) + } +} + +func TestAgenticChat_NoToolsPlainChat(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{{textChunk("Hello there.")}}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "hi"}}, + Tools: []aguitypes.Tool{}, // no tools + } + out := runChat(t, m, in) + if !strings.Contains(out, "Hello there.") || !strings.Contains(out, `"type":"RUN_FINISHED"`) { + t.Errorf("tools:[] should behave like a plain chat turn:\n%s", out) + } + if strings.Contains(out, `"type":"TOOL_CALL_START"`) { + t.Errorf("no tool call expected:\n%s", out) + } +} + +func TestAgenticChat_EmptyIDOpenBuffersUntilClose(t *testing.T) { + // OPEN carries an empty call_id; the authoritative id arrives at CLOSE. The tap + // must NOT emit a START/ARGS with an empty toolCallId (the SDK would drop it). + m := &scriptedModel{turns: [][]*schema.Message{{ + tcOpen(0, "", "confirm_booking"), // empty id + tcArg(0, "", "confirm_booking", `{"flight":"AA9"}`), // delta, still no id + tcArg(0, "call_late", "confirm_booking", ""), // CLOSE backfills id + }}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Book it."}}, + Tools: []aguitypes.Tool{confirmBookingTool()}, + } + out := runChat(t, m, in) + if c := count(out, `"type":"TOOL_CALL_START"`); c != 1 { + t.Errorf("TOOL_CALL_START count = %d, want exactly 1:\n%s", c, out) + } + if !strings.Contains(out, `"toolCallId":"call_late"`) { + t.Errorf("START must use the CLOSE-backfilled id:\n%s", out) + } + if !strings.Contains(out, `AA9`) { + t.Errorf("buffered arg fragment must be flushed:\n%s", out) + } + if strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("unexpected RUN_ERROR:\n%s", out) + } +} + +func TestAgenticChat_MalformedToolsError(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{{textChunk("unused")}}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "hi"}}, + Tools: []aguitypes.Tool{{Name: "", Description: "no name"}}, // empty name + } + out := runChat(t, m, in) + if !strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("malformed tools must yield RUN_ERROR:\n%s", out) + } +} diff --git a/sdks/community/go/example/server/internal/agent/loop.go b/sdks/community/go/example/server/internal/agent/loop.go new file mode 100644 index 0000000000..4b3e8c38ad --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/loop.go @@ -0,0 +1,618 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "strconv" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +// defaultSystemPrompt is used when the request carries no system/developer +// message and steers the model toward the read-only file_read tool. +const defaultSystemPrompt = "You are a helpful assistant operating in a read-only file workspace. " + + "Use the file_read tool (with a workspace-relative path) to read file contents when the user asks about files. " + + "You cannot create, modify, or delete files. Be concise." + +// ensureSystemPrompt guarantees a leading system message. prompt is the route's +// posture; an empty prompt falls back to the default (which steers toward the +// read-only file_read tool). +// Feature routes pass their own prompt so they never advertise file_read. +func ensureSystemPrompt(messages []*schema.Message, prompt string) []*schema.Message { + for _, m := range messages { + if m.Role == schema.System { + return messages + } + } + if prompt == "" { + prompt = defaultSystemPrompt + } + return append([]*schema.Message{schema.SystemMessage(prompt)}, messages...) +} + +// Deps are the shared dependencies for running an agent turn. +// +// Deps is shared across all concurrent requests; never mutate it per request. +// Per-request tool binding derives a new model from BaseModel via WithTools inside +// Run (WithTools returns a fresh instance), so concurrent requests with different +// client tools never clobber each other. +type Deps struct { + Model model.ToolCallingChatModel // file_read-bound; used by /agentic and the ServerOnly path + BaseModel model.ToolCallingChatModel // unbound; WithTools is applied per request on the ClientTools path + Tools *Toolset + Store *runstore.Store + AutoApprove bool + MaxIterations int // <= 0 falls back to config.DefaultMaxIterations + Logger *slog.Logger + // Provider is the model-backend name (e.g. "openai"). It gates multimodal + // content forwarding: only "openai" sends image parts to the model; other + // backends receive text-only messages. + Provider string +} + +// Run executes one AG-UI run: it streams the full event surface for either a +// fresh request or a resume of a previously interrupted run. +// +// Each call emits a self-contained event sequence (RUN_STARTED … RUN_FINISHED). +// A resume reuses the original threadID/runID (that is how the paused run is keyed), +// so the same runID legitimately appears in two responses. A consumer that +// concatenates the events of multiple /agentic responses for one runID and runs the +// SDK's ValidateSequence over the merged log will see a second RUN_STARTED and reject +// it — treat each response as its own sequence, not one continuous log. +func Run(ctx context.Context, emit *Emitter, in *aguitypes.RunAgentInput, deps *Deps, cfg RunConfig, threadID, runID string) { + emit.RunStarted() + + // Per-request model. On the ClientTools track, bind the client-defined tools + // (and optionally file_read) to a fresh instance derived from BaseModel — + // WithTools returns a new instance, so concurrent requests never clobber each + // other and the shared deps.Model/BaseModel are never mutated. + cm := deps.Model + clientNames := make(map[string]bool, len(in.Tools)) + if cfg.ToolPolicy == ClientTools { + clientInfos, err := clientToolInfos(in.Tools) + if err != nil { + emit.RunError(err.Error()) + return + } + for _, t := range in.Tools { + clientNames[t.Name] = true + } + infos := clientInfos + if cfg.ExposeFileRead { + infos = append(append([]*schema.ToolInfo{}, deps.Tools.Infos()...), clientInfos...) + } + bound, err := deps.BaseModel.WithTools(infos) + if err != nil { + emit.RunError("failed to bind client tools") + return + } + cm = bound + } + + key := runstore.Key(threadID, runID) + var ( + st *State + messages []*schema.Message + ) + + // Resume path: rehydrate a paused run and settle the pending tool calls. + if len(in.Resume) > 0 { + // Peek with a non-destructive Load so a malformed/partial resume can be + // retried. Claiming (LoadAndDelete) before validation would destroy the + // paused run on an ordinary user error — a typo'd InterruptID or a resume + // that addresses only some pending calls — with no way for the client to + // re-submit a corrected resume. + saved, ok := deps.Store.Load(key) + if !ok { + emit.RunError("cannot resume: no paused run found for this thread/run " + + "(it may have expired, already been resumed, or the server restarted)") + return + } + + approvals := approvalsFromResume(in.Resume) + // Every pending tool call needs an explicit decision; otherwise the + // zero-value map lookup would silently deny an un-addressed call. Validate + // before claiming so a failed validation leaves the paused run intact. + undecided := 0 + for _, tc := range saved.Pending { + if _, decided := approvals[tc.ID]; !decided { + undecided++ + } + } + if undecided == len(saved.Pending) && len(saved.Pending) > 0 { + emit.RunError("resume entries do not match any pending tool call for this run") + return + } + if undecided > 0 { + for _, tc := range saved.Pending { + if _, decided := approvals[tc.ID]; !decided { + emit.RunError(fmt.Sprintf("resume did not address pending tool call %q", tc.ID)) + return + } + } + } + + // Validation passed — now claim the run atomically so two concurrent + // resumes cannot both execute the pending tool calls. The loser of a race + // gets a clean RUN_ERROR rather than a double-execution. Commit to the + // `saved` snapshot read above; the claim is only for exclusivity. + if _, claimed := deps.Store.LoadAndDelete(key); !claimed { + emit.RunError("cannot resume: the paused run was claimed by a concurrent resume") + return + } + st = StateFromSnapshot(saved.State) + messages = saved.Messages + emit.StateSnapshot(st.Snapshot()) + + emit.StepStarted("tools") + // Re-surface the proposal (START/ARGS/END) in this new stream so a client + // rendering tool cards has the call to attach the result to — the original + // proposal was emitted in the prior (interrupted) response, not this one. + for _, tc := range saved.Pending { + emitToolProposal(emit, tc) + } + settlePendingToolCalls(ctx, emit, deps, saved.Pending, &messages, st, approvals) + emit.StepFinished("tools") + } + + // Fresh path. + if st == nil { + st = NewState() + st.Seed(in.State) + messages = ensureSystemPrompt(toEinoMessages(in.Messages, deps.Provider), cfg.SystemPrompt) + emit.StateSnapshot(st.Snapshot()) + } + + maxIter := deps.MaxIterations + if maxIter <= 0 { + maxIter = config.DefaultMaxIterations + } + if maxIter > config.MaxIterationsCeiling { + deps.Logger.Warn("clamping MaxIterations to ceiling", + "requested", maxIter, "ceiling", config.MaxIterationsCeiling) + maxIter = config.MaxIterationsCeiling + } + + converged := false + for iter := 0; iter < maxIter; iter++ { + if emit.Err() != nil || ctx.Err() != nil { + return // client disconnected + } + + emit.StepStarted("llm") + assistant, err := streamTurn(ctx, emit, cm, messages, cfg.StreamToolCalls) + emit.StepFinished("llm") + if err != nil { + // A canceled context or an already-gated emitter means the client + // disconnected or the server is shutting down — both normal for an SSE + // server. Don't log at ERROR, and skip RUN_ERROR: the emitter is either + // already gated, or the SDK's encoder drops the write on the canceled + // context anyway (it checks ctx.Err() before encoding), so the terminal + // event could not reach the client. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || emit.Err() != nil { + deps.Logger.Info("run aborted (client gone or shutting down)", "thread", threadID, "run", runID) + return + } + deps.Logger.Error("model turn failed", "thread", threadID, "run", runID, "error", err) + emit.RunError("the agent failed to generate a response") + return + } + messages = append(messages, assistant) + + // Validate model-emitted tool calls before proposing or executing them. + // actionable is the subset worth running; assistant.ToolCalls is narrowed to + // the calls kept on the message (each retaining a matching tool response). + actionable := validateToolCalls(emit, deps.Logger, assistant, &messages) + + if len(assistant.ToolCalls) == 0 { + converged = true + break // final answer (no usable tool calls) + } + if len(actionable) == 0 { + continue // only malformed calls this turn; let the model retry with the corrections + } + + // Streaming tools-track (01/02/03): the TOOL_CALL_* already streamed during + // the {llm} step via streamTurn's tap, so do NOT re-emit (that would double + // every call). Route the finalized calls instead — never interrupt. + if cfg.StreamToolCalls { + serverCalls, clientCalls := classifyToolCalls(actionable, clientNames) + if len(clientCalls) > 0 { + // Hand-back: the server can't execute client-defined tools. Finish + // with a plain RUN_FINISHED so the client fulfills and starts Run B. + // (Mixed server+client: also hand back; don't run the server tool — + // avoids the "who echoes the server result in Run B" ambiguity.) + // + // Any server call in this turn won't be executed, so answer it with a + // synthetic tool result; otherwise the MESSAGES_SNAPSHOT would carry an + // assistant tool call with no matching tool response, which some SDK + // sequence validators reject. (Only reachable if the model hallucinates + // an unknown tool alongside a real client call — a narrow case.) + for _, sc := range serverCalls { + messages = append(messages, schema.ToolMessage( + `{"error":"not executed: a client tool in this turn took priority"}`, sc.ID)) + } + emit.MessagesSnapshot(toAGUIMessages(messages)) + emit.RunFinishedSuccess() + return + } + // Server-only this turn: execute (emits TOOL_CALL_RESULT only; START/ARGS/END + // already streamed) and continue the loop. No interrupt on this track. + settlePendingToolCalls(ctx, emit, deps, serverCalls, &messages, st, nil) + continue + } + + emit.StepStarted("tools") + for _, tc := range actionable { + emitToolProposal(emit, tc) + } + + if !deps.AutoApprove && !cfg.NeverInterrupt { + // Human-in-the-loop: pause for approval and finish with an interrupt. + // cfg.NeverInterrupt forces the auto-execute path below even when + // AutoApprove is off — feature routes the Dart client can't resume set + // it so a stray non-streaming config can never deadlock on an interrupt. + interrupts := make([]aguitypes.Interrupt, 0, len(actionable)) + for _, tc := range actionable { + emit.ActivitySnapshot(aguievents.GenerateMessageID(), "approval_request", + map[string]any{"text": fmt.Sprintf("Agent wants to call %s with %s — approve?", tc.Function.Name, tc.Function.Arguments)}) + interrupts = append(interrupts, aguitypes.Interrupt{ + ID: tc.ID, + Reason: "tool_call", + Message: fmt.Sprintf("Approve %s(%s)?", tc.Function.Name, tc.Function.Arguments), + ToolCallID: tc.ID, + ResponseSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"approved": map[string]any{"type": "boolean"}}, + "required": []any{"approved"}, + }, + }) + } + // Flip the status before snapshotting so the persisted state matches the + // STATE_DELTA the client just saw; otherwise a resume's STATE_SNAPSHOT + // would regress the client back to the pre-pause status. + emit.StateDelta(st.SetStatus("awaiting_approval")) + deps.Store.Save(key, &runstore.Saved{ + Messages: messages, + Pending: actionable, + State: st.Snapshot(), + }) + emit.StepFinished("tools") + emit.MessagesSnapshot(toAGUIMessages(messages)) + emit.RunFinishedInterrupt(interrupts) + return + } + + // Auto-approve: execute immediately and continue the loop. + settlePendingToolCalls(ctx, emit, deps, actionable, &messages, st, nil) + emit.StepFinished("tools") + } + + if !converged { + // Hit the iteration cap with tool calls still pending — the model never + // produced a final answer, so this is an error, not a successful run. + deps.Logger.Warn("agent did not converge within iteration budget", + "thread", threadID, "run", runID, "maxIterations", maxIter) + emit.MessagesSnapshot(toAGUIMessages(messages)) + emit.RunError(fmt.Sprintf("agent did not converge within %d iterations", maxIter)) + return + } + + emit.StateDelta(st.SetStatus("done")) + // agent_complete is emitted only on the converged (success) path; the error and + // interrupt terminal paths intentionally omit it. + emit.Custom("agent_complete", map[string]any{"toolCalls": st.ToolCalls, "filesRead": st.FilesRead}) + emit.MessagesSnapshot(toAGUIMessages(messages)) + emit.RunFinishedSuccess() +} + +// streamTurn streams one model turn, emitting reasoning and text events as +// chunks arrive, and returns the merged assistant message (Extra preserved so +// the codex model's reasoning items thread across turns). +// +// When streamToolCalls is true, it also surfaces tool calls live: as the model +// streams a call (OPEN chunk with id+name → arg-fragment chunks → empty CLOSE +// chunk), it emits TOOL_CALL_START (lazily, once a non-empty id AND name are +// known — buffering arg fragments until then, since the SDK rejects an empty +// toolCallId), TOOL_CALL_ARGS per non-empty fragment, and TOOL_CALL_END for every +// opened call at stream EOF. Callers that stream MUST NOT also emitToolProposal +// for the same calls (that double-emits). When false, tool calls are left for the +// caller to surface post-turn. +func streamTurn(ctx context.Context, emit *Emitter, cm model.ToolCallingChatModel, messages []*schema.Message, streamToolCalls bool) (*schema.Message, error) { + sr, err := cm.Stream(ctx, messages) + if err != nil { + return nil, err + } + defer sr.Close() + + var chunks []*schema.Message + var textID string // assigned a fresh id each time a text block opens + var reasoningID string // assigned a fresh id each time a reasoning block opens + textOpen, reasoningOpen := false, false + + // Streaming tool-call tap state, keyed by tool-call identity (Index, stable per + // call across OPEN/delta/CLOSE chunks). + type tcStream struct { + started bool + id, name string + buffered []string // arg fragments held until id+name are known + } + tcs := map[string]*tcStream{} + var tcOrder []string + + closeReasoning := func() { + if reasoningOpen { + emit.ReasoningMessageEnd(reasoningID) + emit.ReasoningEnd(reasoningID) + reasoningOpen = false + } + } + closeText := func() { + if textOpen { + emit.TextEnd(textID) + textOpen = false + } + } + // streamToolCallChunk surfaces one chunk's tool-call fragments live. + streamToolCallChunk := func(chunk *schema.Message) { + if len(chunk.ToolCalls) == 0 { + return + } + // A tool call ends any open text/reasoning block so blocks never overlap. + closeReasoning() + closeText() + for _, tc := range chunk.ToolCalls { + key := toolCallKey(tc) + st := tcs[key] + if st == nil { + st = &tcStream{} + tcs[key] = st + tcOrder = append(tcOrder, key) + } + if tc.Function.Name != "" { + st.name = tc.Function.Name + } + if tc.ID != "" { + st.id = tc.ID + } + frag := tc.Function.Arguments + switch { + case st.started: + emit.ToolArgs(st.id, frag) // emitter skips empty + case st.id != "" && st.name != "": + emit.ToolStart(st.id, st.name) + st.started = true + for _, b := range st.buffered { + emit.ToolArgs(st.id, b) + } + st.buffered = nil + emit.ToolArgs(st.id, frag) + case frag != "": + st.buffered = append(st.buffered, frag) // hold until id+name known + } + } + } + // endStreamedToolCalls closes every opened call. Text/reasoning are closed first + // so an open TEXT block (a model that streamed text after the tool call) is + // terminated before the tool-call END, keeping blocks non-overlapping. + endStreamedToolCalls := func() { + closeReasoning() + closeText() + for _, key := range tcOrder { + if st := tcs[key]; st.started { + emit.ToolEnd(st.id) + st.started = false // idempotent: never emit END twice for the same call + } + } + } + // closeOpenBlocks balances any started message block. Deferred so that EVERY + // exit path — EOF, a mid-stream Recv error, or ctx cancel — closes open + // TEXT/REASONING blocks AND opened tool calls on the wire, rather than leaving a + // client hanging on a dangling TOOL_CALL_START or open message. + closeOpenBlocks := func() { + if streamToolCalls { + endStreamedToolCalls() + return // endStreamedToolCalls already closed reasoning+text + } + closeReasoning() + closeText() + } + defer closeOpenBlocks() + + for { + if err := ctx.Err(); err != nil { + return nil, err // client gone; stop draining the model stream + } + if emit.Err() != nil { + return nil, emit.Err() + } + chunk, recvErr := sr.Recv() + if errors.Is(recvErr, io.EOF) { + break + } + if recvErr != nil { + return nil, recvErr // deferred closeOpenBlocks balances the stream + } + if chunk.ReasoningContent != "" { + // Reasoning after text has started (a future provider may interleave): + // close the open TEXT block first so blocks never overlap on the wire. + if textOpen { + emit.TextEnd(textID) + textOpen = false + } + if !reasoningOpen { + // Fresh id per block so a reasoning span that reopens after text is + // never a re-opened same-id block. + reasoningID = aguievents.GenerateMessageID() + emit.ReasoningStart(reasoningID) + emit.ReasoningMessageStart(reasoningID) + reasoningOpen = true + } + emit.ReasoningContent(reasoningID, chunk.ReasoningContent) + } + if chunk.Content != "" { + closeReasoning() // reasoning precedes the visible answer + if !textOpen { + // Fresh id per block so a text span that reopens after reasoning is + // never a re-opened same-id block. + textID = aguievents.GenerateMessageID() + emit.TextStart(textID) + textOpen = true + } + emit.TextContent(textID, chunk.Content) + } + if streamToolCalls { + streamToolCallChunk(chunk) + } + chunks = append(chunks, chunk) + } + // TOOL_CALL_END for every opened call is emitted by the deferred closeOpenBlocks, + // so it fires on the EOF path AND on a mid-stream error (no dangling START). + if len(chunks) == 0 { + return nil, fmt.Errorf("empty model stream") + } + return schema.ConcatMessages(chunks) +} + +// toolCallKey identifies a streaming tool call stably across its OPEN/delta/CLOSE +// chunks. Index is non-nil on every chunk from both providers; the fallbacks keep +// a malformed stream from collapsing distinct calls onto one key. +func toolCallKey(tc schema.ToolCall) string { + if tc.Index != nil { + return "i" + strconv.Itoa(*tc.Index) + } + if tc.ID != "" { + return "d" + tc.ID + } + return "p0" +} + +// validateToolCalls partitions model-emitted tool calls into the calls kept on the +// assistant message (every kept call retains a matching tool response, which the +// provider requires) and the actionable subset worth proposing/executing. +// +// A call with an empty name or non-JSON arguments is malformed: it is kept on the +// message and answered with a corrective tool-role message so the model can recover +// in-turn, but it is NOT proposed or executed. A call with an empty ID is +// uncorrelatable (no tool response can be keyed to it) so it is dropped from the +// assistant message entirely. This stops an empty toolCallName from reaching the SDK +// encoder, which rejects it — a rejection the emitter would otherwise misread as a +// client disconnect, silently killing the run with no RUN_ERROR. +func validateToolCalls(emit *Emitter, logger *slog.Logger, assistant *schema.Message, messages *[]*schema.Message) []schema.ToolCall { + return validateToolCallsOpt(emit, logger, assistant, messages, true) +} + +// validateToolCallsQuiet behaves like validateToolCalls but does NOT emit a +// TOOL_CALL_RESULT event for a malformed call — it only threads the corrective +// tool-role message back into the conversation. Routes whose contract forbids +// tool-call events on the wire (e.g. /shared_state, /predictive_state_updates) use +// this so a malformed model call can't leak a TOOL_CALL_RESULT. +func validateToolCallsQuiet(logger *slog.Logger, assistant *schema.Message, messages *[]*schema.Message) []schema.ToolCall { + return validateToolCallsOpt(nil, logger, assistant, messages, false) +} + +func validateToolCallsOpt(emit *Emitter, logger *slog.Logger, assistant *schema.Message, messages *[]*schema.Message, emitResults bool) []schema.ToolCall { + kept := make([]schema.ToolCall, 0, len(assistant.ToolCalls)) + actionable := make([]schema.ToolCall, 0, len(assistant.ToolCalls)) + corrective := func(tc schema.ToolCall, result string) { + if emitResults { + emit.ToolResult(aguievents.GenerateMessageID(), tc.ID, result) + } + *messages = append(*messages, schema.ToolMessage(result, tc.ID)) + kept = append(kept, tc) + } + for _, tc := range assistant.ToolCalls { + switch { + case tc.ID == "": + logger.Warn("dropping tool call with empty id", "name", tc.Function.Name) + case tc.Function.Name == "": + corrective(tc, `{"error":"tool call had an empty function name"}`) + case !json.Valid([]byte(tc.Function.Arguments)): + corrective(tc, fmt.Sprintf(`{"error":"tool arguments for %q were not valid JSON"}`, tc.Function.Name)) + default: + kept = append(kept, tc) + actionable = append(actionable, tc) + } + } + assistant.ToolCalls = kept + return actionable +} + +// emitToolProposal surfaces a proposed tool call (start/args/end), independent of +// whether it will be executed now or after an approval interrupt. +func emitToolProposal(emit *Emitter, tc schema.ToolCall) { + emit.ToolStart(tc.ID, tc.Function.Name) + emit.ToolArgs(tc.ID, tc.Function.Arguments) + emit.ToolEnd(tc.ID) +} + +// settlePendingToolCalls executes (or, when denied, records a denial for) each +// tool call, emitting the result and threading a role=tool message back into the +// conversation. A nil approvals map means "approve all" (auto-approve path). +func settlePendingToolCalls(ctx context.Context, emit *Emitter, deps *Deps, calls []schema.ToolCall, messages *[]*schema.Message, st *State, approvals map[string]bool) { + for _, tc := range calls { + approved := approvals == nil || approvals[tc.ID] + var result string + if approved { + emit.ActivitySnapshot(aguievents.GenerateMessageID(), "tool_use", + map[string]any{"text": fmt.Sprintf("Running %s(%s)", tc.Function.Name, tc.Function.Arguments)}) + out, err := deps.Tools.Run(ctx, tc.Function.Name, tc.Function.Arguments) + if err != nil { + // A failed read must not be recorded as a file successfully read. + // TODO(prod): the raw error can carry filesystem-shape detail. Feed the + // verbatim diagnostic to the model but sanitize what reaches the client. + out = fmt.Sprintf(`{"error":%q}`, err.Error()) + emit.StateDelta(st.SetStatus("read_error")) + } else { + emit.StateDelta(st.RecordFileRead(extractPath(tc.Function.Arguments))) + } + result = out + } else { + result = `{"denied":true,"reason":"user did not approve this tool call"}` + } + emit.ToolResult(aguievents.GenerateMessageID(), tc.ID, result) + *messages = append(*messages, schema.ToolMessage(result, tc.ID)) + } +} + +// approvalsFromResume maps resume entries to per-tool-call approval. An entry is +// approved when status is "resolved" and its payload does not carry approved:false. +func approvalsFromResume(entries []aguitypes.ResumeEntry) map[string]bool { + approvals := make(map[string]bool, len(entries)) + for _, e := range entries { + approved := e.Status == aguitypes.ResumeStatusResolved + if approved { + if m, ok := e.Payload.(map[string]any); ok { + if v, ok := m["approved"].(bool); ok { + approved = v + } + } + } + approvals[e.InterruptID] = approved + } + return approvals +} + +// extractPath pulls the path out of file_read arguments for a human-readable +// state/activity label only. It is best-effort: malformed args or a missing path +// both yield "(unknown)". The tool itself validates the real arguments. +func extractPath(argsJSON string) string { + var a struct { + Path string `json:"path"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + if a.Path == "" { + return "(unknown)" + } + return a.Path +} diff --git a/sdks/community/go/example/server/internal/agent/loop_test.go b/sdks/community/go/example/server/internal/agent/loop_test.go new file mode 100644 index 0000000000..0cd6176dd1 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/loop_test.go @@ -0,0 +1,317 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "io" + "log/slog" + "strings" + "sync" + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +// scriptedModel returns a queued sequence of turns, one per Stream call. The queue +// persists across Run calls (the same model instance is reused), which lets a single +// test drive a multi-turn loop and an interrupt->resume cycle. +type scriptedModel struct { + mu sync.Mutex + turns [][]*schema.Message +} + +func (m *scriptedModel) next() []*schema.Message { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.turns) == 0 { + return nil + } + t := m.turns[0] + m.turns = m.turns[1:] + return t +} + +func (m *scriptedModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) { + return schema.ConcatMessages(m.next()) +} + +func (m *scriptedModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) { + chunks := m.next() + sr, sw := schema.Pipe[*schema.Message](len(chunks) + 1) + go func() { + defer sw.Close() + for _, c := range chunks { + sw.Send(c, nil) + } + }() + return sr, nil +} + +func (m *scriptedModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { + return m, nil +} + +func toolCallChunk(id, name, args string) *schema.Message { + return &schema.Message{ + Role: schema.Assistant, + ToolCalls: []schema.ToolCall{{ + ID: id, + Type: "function", + Function: schema.FunctionCall{Name: name, Arguments: args}, + }}, + } +} + +func textChunk(s string) *schema.Message { + return &schema.Message{Role: schema.Assistant, Content: s} +} + +// runWithModel drives one Run against the scripted model and returns the raw SSE +// stream the client would receive. +func runWithModel(t *testing.T, cm model.ToolCallingChatModel, in *aguitypes.RunAgentInput, store *runstore.Store, autoApprove bool, maxIter int) string { + t.Helper() + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + tools, err := NewReadOnlyToolset(t.TempDir()) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + deps := &Deps{ + Model: cm, + Tools: tools, + Store: store, + AutoApprove: autoApprove, + MaxIterations: maxIter, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + Run(context.Background(), emit, in, deps, DefaultRunConfig(), in.ThreadID, in.RunID) + _ = w.Flush() + return buf.String() +} + +func TestRunRecoversFromEmptyNameToolCall(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("call1", "", `{}`)}, // malformed: empty function name + {textChunk("all done")}, // final answer on the retry + }} + in := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + out := runWithModel(t, m, in, runstore.New(), true, 8) + + if strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("empty-name tool call must not error the run:\n%s", out) + } + if !strings.Contains(out, `"type":"RUN_FINISHED"`) { + t.Errorf("expected RUN_FINISHED:\n%s", out) + } + if !strings.Contains(out, "empty function name") { + t.Errorf("expected a corrective tool result for the empty name:\n%s", out) + } +} + +func TestRunReportsNonConvergence(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("c1", "file_read", `{"path":"a"}`)}, + {toolCallChunk("c2", "file_read", `{"path":"b"}`)}, + {toolCallChunk("c3", "file_read", `{"path":"c"}`)}, + }} + in := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + out := runWithModel(t, m, in, runstore.New(), true, 2) // cap below the script length + + if !strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("expected RUN_ERROR on non-convergence:\n%s", out) + } + if !strings.Contains(out, "did not converge") { + t.Errorf("expected a non-convergence message:\n%s", out) + } +} + +func TestRunErrorsOnEmptyModelStream(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{{}}} // a turn with no chunks + in := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + out := runWithModel(t, m, in, runstore.New(), true, 8) + + if !strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("expected RUN_ERROR on an empty model stream:\n%s", out) + } +} + +func TestInterruptThenResumeApprove(t *testing.T) { + store := runstore.New() + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("call1", "file_read", `{"path":"x"}`)}, // turn 1: propose a tool + {textChunk("read complete")}, // turn 2: final answer after resume + }} + + in1 := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + out1 := runWithModel(t, m, in1, store, false, 8) + if !strings.Contains(out1, `"type":"RUN_FINISHED"`) { + t.Fatalf("expected an interrupt RUN_FINISHED on the first run:\n%s", out1) + } + if _, ok := store.Load(runstore.Key("t", "r")); !ok { + t.Fatal("expected a saved paused run after the interrupt") + } + + in2 := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Resume: []aguitypes.ResumeEntry{{ + InterruptID: "call1", + Status: aguitypes.ResumeStatusResolved, + Payload: map[string]any{"approved": true}, + }}, + } + out2 := runWithModel(t, m, in2, store, false, 8) + if strings.Contains(out2, `"type":"RUN_ERROR"`) { + t.Errorf("resume-approve should not error:\n%s", out2) + } + if !strings.Contains(out2, `"type":"TOOL_CALL_START"`) { + t.Errorf("resume should re-emit the tool proposal (TOOL_CALL_START):\n%s", out2) + } + if !strings.Contains(out2, `"type":"RUN_FINISHED"`) { + t.Errorf("resume-approve should finish the run:\n%s", out2) + } +} + +func TestInterruptThenResumeDeny(t *testing.T) { + store := runstore.New() + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("call1", "file_read", `{"path":"x"}`)}, + {textChunk("okay, skipping that")}, + }} + + in1 := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + runWithModel(t, m, in1, store, false, 8) + + in2 := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Resume: []aguitypes.ResumeEntry{{ + InterruptID: "call1", + Status: aguitypes.ResumeStatusResolved, + Payload: map[string]any{"approved": false}, + }}, + } + out2 := runWithModel(t, m, in2, store, false, 8) + if !strings.Contains(out2, "did not approve") { + t.Errorf("expected the denial result threaded back:\n%s", out2) + } + if !strings.Contains(out2, `"type":"RUN_FINISHED"`) { + t.Errorf("resume-deny should still finish the run:\n%s", out2) + } +} + +// TestStreamTurnConcurrentSharedModel guards the assumption in main.go that one +// tool-bound model can be shared across concurrent /agentic requests: concurrent +// streamTurn calls against a single model must be race-free. Meaningful under -race. +func TestStreamTurnConcurrentSharedModel(t *testing.T) { + shared := &fakeModel{chunks: []*schema.Message{ + {Role: schema.Assistant, Content: "hello"}, + }} + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), "t", "r", nil) + msg, err := streamTurn(context.Background(), emit, shared, nil, false) + if err != nil { + t.Errorf("streamTurn: %v", err) + return + } + if msg.Content != "hello" { + t.Errorf("content = %q, want hello", msg.Content) + } + }() + } + wg.Wait() +} + +// TestFailedResumePreservesPausedRunForRetry is the regression guard for the +// resume-claim fix: a resume that fails validation must NOT destroy the paused +// run, so the client can re-submit a corrected resume. (Before the fix the path +// claimed the run with LoadAndDelete before validating, so any bad resume was +// unrecoverable.) +func TestFailedResumePreservesPausedRunForRetry(t *testing.T) { + store := runstore.New() + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("call1", "file_read", `{"path":"x"}`)}, // propose -> interrupt + {textChunk("read complete")}, // final answer after a corrected resume + }} + + in1 := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + runWithModel(t, m, in1, store, false, 8) + key := runstore.Key("t", "r") + if _, ok := store.Load(key); !ok { + t.Fatal("expected a saved paused run after the interrupt") + } + + // A resume that addresses no pending call fails validation. + badResume := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Resume: []aguitypes.ResumeEntry{{ + InterruptID: "does-not-exist", + Status: aguitypes.ResumeStatusResolved, + Payload: map[string]any{"approved": true}, + }}, + } + out := runWithModel(t, m, badResume, store, false, 8) + if !strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Fatalf("expected RUN_ERROR for an unmatched resume:\n%s", out) + } + if _, ok := store.Load(key); !ok { + t.Fatal("a failed-validation resume must leave the paused run intact for a retry") + } + + // A corrected resume then succeeds and drives the run to completion. + goodResume := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Resume: []aguitypes.ResumeEntry{{ + InterruptID: "call1", + Status: aguitypes.ResumeStatusResolved, + Payload: map[string]any{"approved": true}, + }}, + } + out2 := runWithModel(t, m, goodResume, store, false, 8) + if strings.Contains(out2, `"type":"RUN_ERROR"`) { + t.Errorf("the corrected resume should not error:\n%s", out2) + } + if !strings.Contains(out2, `"type":"RUN_FINISHED"`) { + t.Errorf("the corrected resume should finish the run:\n%s", out2) + } + if _, ok := store.Load(key); ok { + t.Error("the paused run should be claimed (deleted) after a successful resume") + } +} + +func TestResumeWithUnmatchedEntryErrors(t *testing.T) { + store := runstore.New() + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("call1", "file_read", `{"path":"x"}`)}, + }} + + in1 := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r"} + runWithModel(t, m, in1, store, false, 8) + + in2 := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Resume: []aguitypes.ResumeEntry{{ + InterruptID: "does-not-exist", + Status: aguitypes.ResumeStatusResolved, + Payload: map[string]any{"approved": true}, + }}, + } + out2 := runWithModel(t, m, in2, store, false, 8) + if !strings.Contains(out2, `"type":"RUN_ERROR"`) { + t.Errorf("expected RUN_ERROR for an unmatched resume entry:\n%s", out2) + } + if !strings.Contains(out2, "do not match any pending") { + t.Errorf("expected the clearer unmatched-entry message:\n%s", out2) + } +} diff --git a/sdks/community/go/example/server/internal/agent/model.go b/sdks/community/go/example/server/internal/agent/model.go new file mode 100644 index 0000000000..8063ca70da --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/model.go @@ -0,0 +1,35 @@ +package agent + +import ( + "context" + "fmt" + "os" + + openaimodel "github.com/cloudwego/eino-ext/components/model/openai" + "github.com/cloudwego/eino/components/model" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" +) + +// NewModel constructs the eino chat model for the configured provider. +// +// - "openai": a plain OPENAI_API_KEY harness (Chat Completions). +// +// The standalone source example also had a prototype subscription provider +// backed by local replace directives. This monorepo example keeps only the +// reproducible provider path. +func NewModel(ctx context.Context, cfg config.Config) (model.ToolCallingChatModel, error) { + switch cfg.Provider { + case "openai", "": + key := os.Getenv("OPENAI_API_KEY") + if key == "" { + return nil, fmt.Errorf("openai provider requires OPENAI_API_KEY") + } + return openaimodel.NewChatModel(ctx, &openaimodel.ChatModelConfig{ + APIKey: key, + Model: cfg.Model, + }) + default: + return nil, fmt.Errorf("unknown MODEL_PROVIDER %q (want openai)", cfg.Provider) + } +} diff --git a/sdks/community/go/example/server/internal/agent/multimodal_integration_test.go b/sdks/community/go/example/server/internal/agent/multimodal_integration_test.go new file mode 100644 index 0000000000..b82b8daea1 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/multimodal_integration_test.go @@ -0,0 +1,135 @@ +//go:build integration + +package agent + +// Run with: go test -tags integration -run TestMultimodalIntegration ./internal/agent/ +// Requires OPENAI_API_KEY in the environment. Override the model with MODEL=gpt-4o. +// gpt-4o-mini is used by default (cheapest vision-capable model). + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "image" + "image/color" + "image/png" + "io" + "log/slog" + "os" + "strings" + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +// TestMultimodalIntegration drives a full Run() against the real OpenAI API with +// an inline image, confirming that the multimodal forwarding path in convert.go +// is wired correctly end-to-end. A RUN_ERROR or missing TEXT_MESSAGE_CONTENT +// indicates the image was dropped before reaching the model. +func TestMultimodalIntegration(t *testing.T) { + if os.Getenv("OPENAI_API_KEY") == "" { + t.Skip("OPENAI_API_KEY not set") + } + + modelName := os.Getenv("MODEL") + if modelName == "" { + modelName = "gpt-4o-mini" + } + + ctx := context.Background() + base, err := NewModel(ctx, config.Config{Provider: "openai", Model: modelName}) + if err != nil { + t.Fatalf("NewModel: %v", err) + } + + tools, err := NewReadOnlyToolset(t.TempDir()) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + boundModel, err := base.WithTools(tools.Infos()) + if err != nil { + t.Fatalf("WithTools: %v", err) + } + + in := &aguitypes.RunAgentInput{ + ThreadID: "t-multimodal", + RunID: "r-multimodal", + Messages: []aguitypes.Message{ + { + ID: "msg-1", + Role: aguitypes.RoleUser, + // []InputContent exercises toEinoUserMessage → toEinoImagePart in convert.go. + Content: []aguitypes.InputContent{ + { + Type: aguitypes.InputContentTypeText, + Text: "What color is this image? Reply with exactly one word.", + }, + { + Type: aguitypes.InputContentTypeImage, + Source: &aguitypes.InputContentSource{ + Type: aguitypes.InputContentSourceTypeData, + Value: redPNG100x100(t), + MimeType: "image/png", + }, + }, + }, + }, + }, + } + + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(ctx, w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + deps := &Deps{ + Model: boundModel, + Tools: tools, + Store: runstore.New(), + AutoApprove: true, + MaxIterations: 2, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Provider: "openai", + } + + Run(ctx, emit, in, deps, in.ThreadID, in.RunID) + _ = w.Flush() + out := buf.String() + + if strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("multimodal run produced RUN_ERROR — image may have been dropped or rejected:\n%s", out) + } + if !strings.Contains(out, `"type":"RUN_FINISHED"`) { + t.Errorf("expected RUN_FINISHED:\n%s", out) + } + if !strings.Contains(out, `"type":"TEXT_MESSAGE_CONTENT"`) { + t.Errorf("expected TEXT_MESSAGE_CONTENT — model should have responded to the image:\n%s", out) + } +} + +// redPNG100x100 generates a 100×100 red PNG, writes it to /tmp/test-multimodal.png +// for visual inspection, and returns the raw base64 encoding sent to the model. +func redPNG100x100(t *testing.T) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + red := color.RGBA{R: 255, G: 0, B: 0, A: 255} + for y := range 100 { + for x := range 100 { + img.Set(x, y, red) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode test PNG: %v", err) + } + const path = "/tmp/test-multimodal.png" + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Logf("warning: could not write PNG to %s: %v", path, err) + } else { + t.Logf("test image written to %s", path) + } + return base64.StdEncoding.EncodeToString(buf.Bytes()) +} diff --git a/sdks/community/go/example/server/internal/agent/predictive.go b/sdks/community/go/example/server/internal/agent/predictive.go new file mode 100644 index 0000000000..a8844ba115 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/predictive.go @@ -0,0 +1,157 @@ +package agent + +import ( + "context" + "errors" + "io" + "regexp" + "strings" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/cloudwego/eino/schema" +) + +// PredictiveState runs the /predictive_state_updates flow (request 05): it shows +// the document filling in optimistically WHILE the agent generates it, then settles +// to the committed value. +// +// Signal convention (documented for the client): every STATE_DELTA whose path is +// under "/_predictive" is a PREDICTION — render it ghosted; every other delta is +// COMMITTED. As the model streams the new recipe steps, the growing draft is +// emitted as predictive deltas at "/_predictive/draft". On completion the final +// value is committed at "/recipe/steps" and the "/_predictive" namespace is +// removed. A client that drops every "/_predictive" delta still ends in the correct +// committed state (predictions are an enhancement, not the source of truth). +type PredictiveState struct { + Deps *Deps +} + +const predictiveSystemPrompt = "You are a recipe assistant. When the user asks you to write or " + + "revise the recipe's steps, respond with the new steps only — one step per line, no " + + "numbering or preamble. Each line is one step." + +// predictiveDraftPath is the namespace marking a delta as a (ghosted) prediction. +const predictiveDraftPath = "/_predictive" + +func (p PredictiveState) Run(ctx context.Context, emit *Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + emit.RunStarted() + + doc := seedRecipe(in.State) + emit.StateSnapshot(doc.Snapshot()) + + messages := ensureSystemPrompt(toEinoMessages(in.Messages, p.Deps.Provider), predictiveSystemPrompt) + + emit.StepStarted("llm") + full, ok := p.streamPredictive(ctx, emit, doc, messages) + emit.StepFinished("llm") + if !ok { + return // streamPredictive already emitted RUN_ERROR or the client is gone + } + + // Settle: commit the finalized steps to the real path, then clear the ghost. + // The committed value is computed from the final generation, not "promoted" + // from the last prediction, so a dropped/garbled prediction can't corrupt it. + steps := splitSteps(full) + committed := false + if len(steps) > 0 { + // `add` (create-or-replace for an object member) so the commit lands even if + // the client-seeded recipe had no pre-existing "steps" key (replace would + // fail there and silently drop the generated steps). + commit := []events.JSONPatchOperation{{Op: "add", Path: "/recipe/steps", Value: steps}} + if err := doc.Apply(commit); err == nil { + emit.StateDelta(commit) // committed (not under /_predictive) + committed = true + } + } + // Remove the draft namespace (itself a predictive op — clients that ignore + // predictions never created it and simply ignore this too). + clear := []events.JSONPatchOperation{{Op: "remove", Path: predictiveDraftPath}} + if err := doc.Apply(clear); err == nil { + emit.StateDelta(clear) + } + + // Narrate the committed state honestly: don't claim an update on an empty + // generation. + summary := "Updated the recipe steps." + if !committed { + summary = "I couldn't produce any steps to update." + } + msgID := events.GenerateMessageID() + emit.TextStart(msgID) + emit.TextContent(msgID, summary) + emit.TextEnd(msgID) + + emit.MessagesSnapshot([]aguitypes.Message{ + {ID: msgID, Role: aguitypes.RoleAssistant, Content: summary}, + }) + emit.RunFinishedSuccess() +} + +// streamPredictive streams the model turn, emitting the growing draft as predictive +// STATE_DELTAs under /_predictive/draft. It returns the full generated text and ok +// = true on success; on a model error it emits RUN_ERROR and returns ok = false. +func (p PredictiveState) streamPredictive(ctx context.Context, emit *Emitter, doc *DocState, messages []*schema.Message) (string, bool) { + sr, err := p.Deps.BaseModel.Stream(ctx, messages) + if err != nil { + if errors.Is(err, context.Canceled) || emit.Err() != nil { + return "", false + } + emit.RunError("the agent failed to generate a response") + return "", false + } + defer sr.Close() + + var b strings.Builder + for { + if ctx.Err() != nil || emit.Err() != nil { + return "", false + } + chunk, recvErr := sr.Recv() + if errors.Is(recvErr, io.EOF) { + break + } + if recvErr != nil { + if errors.Is(recvErr, context.Canceled) || emit.Err() != nil { + return "", false + } + emit.RunError("the agent failed to generate a response") + return "", false + } + if chunk.Content == "" { + continue + } + b.WriteString(chunk.Content) + // Emit the current draft as a prediction. `add` to /_predictive is valid + // whether or not it exists yet (it replaces the whole object), so each + // update is a single self-contained op carrying the latest draft text. + op := []events.JSONPatchOperation{{ + Op: "add", Path: predictiveDraftPath, Value: map[string]any{"draft": b.String()}, + }} + if err := doc.Apply(op); err == nil { + emit.StateDelta(op) + } + } + return b.String(), true +} + +// stepListMarker matches a single leading ordered/unordered list marker ("1.", +// "2)", "-", "*") followed by whitespace — and ONLY that. A character-class trim +// would corrupt legitimate steps that begin with a number ("2 eggs, beaten") or a +// hyphen, silently mangling the committed recipe. +var stepListMarker = regexp.MustCompile(`^\s*(?:\d+[.)]|[-*])\s+`) + +// splitSteps turns the model's newline-separated step text into a steps array, +// trimming blank lines and a leading list marker (but not legitimate leading text). +func splitSteps(text string) []any { + var steps []any + for _, line := range strings.Split(text, "\n") { + s := strings.TrimSpace(line) + s = stepListMarker.ReplaceAllString(s, "") + s = strings.TrimSpace(s) + if s != "" { + steps = append(steps, s) + } + } + return steps +} diff --git a/sdks/community/go/example/server/internal/agent/predictive_test.go b/sdks/community/go/example/server/internal/agent/predictive_test.go new file mode 100644 index 0000000000..fcacab1c57 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/predictive_test.go @@ -0,0 +1,172 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "strings" + "testing" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/schema" + jsonpatch "github.com/evanphx/json-patch" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +func runPredictive(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput) string { + t.Helper() + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + tools, err := NewReadOnlyToolset(t.TempDir()) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + deps := &Deps{ + Model: cm, BaseModel: cm, Tools: tools, + Store: runstore.New(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), MaxIterations: 8, + } + PredictiveState{Deps: deps}.Run(context.Background(), emit, in, in.ThreadID, in.RunID) + _ = w.Flush() + return buf.String() +} + +func TestPredictiveState_PredictThenCommit(t *testing.T) { + // The steps generation streams over several text chunks. + m := &scriptedModel{turns: [][]*schema.Message{{ + textChunk("Boil the pasta.\n"), + textChunk("Mince the garlic.\n"), + textChunk("Combine and serve."), + }}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Rewrite the steps."}}, + State: seededRecipeState(), + } + out := runPredictive(t, m, in) + + // Predictive deltas under /_predictive (multiple, as the draft grows). + if c := strings.Count(out, `"path":"/_predictive"`); c < 2 { + t.Errorf("expected multiple predictive /_predictive deltas, got %d:\n%s", c, out) + } + // A committed delta on the real path, and the draft cleared. + if !strings.Contains(out, `"path":"/recipe/steps"`) { + t.Errorf("expected a committed /recipe/steps delta:\n%s", out) + } + if !strings.Contains(out, `"op":"remove","path":"/_predictive"`) { + t.Errorf("expected the predictive draft to be cleared:\n%s", out) + } + if !strings.Contains(out, `"type":"RUN_FINISHED"`) || strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("expected a clean finish:\n%s", out) + } +} + +// TestPredictiveState_DropPredictionsInvariant proves that a client which ignores +// every /_predictive delta still reaches the correct committed state: applying only +// the committed (non-/_predictive) deltas to the initial snapshot yields the final +// steps. +func TestPredictiveState_DropPredictionsInvariant(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{{ + textChunk("Boil the pasta.\n"), + textChunk("Mince the garlic.\n"), + textChunk("Combine and serve."), + }}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Rewrite the steps."}}, + State: seededRecipeState(), + } + out := runPredictive(t, m, in) + + snapshot := firstSnapshot(t, out) + committed := committedDeltas(t, out) // excludes everything under /_predictive + + doc, _ := json.Marshal(snapshot) + for _, ops := range committed { + patchJSON, _ := json.Marshal(ops) + patch, err := jsonpatch.DecodePatch(patchJSON) + if err != nil { + t.Fatalf("decode committed patch: %v", err) + } + doc, err = patch.Apply(doc) + if err != nil { + t.Fatalf("apply committed patch %s: %v", patchJSON, err) + } + } + var final map[string]any + if err := json.Unmarshal(doc, &final); err != nil { + t.Fatalf("unmarshal: %v", err) + } + recipe := final["recipe"].(map[string]any) + steps, _ := recipe["steps"].([]any) + if len(steps) != 3 || steps[0] != "Boil the pasta." || steps[2] != "Combine and serve." { + t.Errorf("dropping predictions must still yield the committed steps; got %#v", recipe["steps"]) + } + // And no leftover /_predictive in the committed-only view. + if _, leftover := final["_predictive"]; leftover { + t.Errorf("committed-only state must not contain /_predictive: %#v", final) + } +} + +// --- SSE parsing helpers (parse the data: lines into events) --- + +func sseData(t *testing.T, out string) []map[string]any { + t.Helper() + var evs []map[string]any + for _, line := range strings.Split(out, "\n") { + rest, ok := strings.CutPrefix(line, "data: ") + if !ok { + continue + } + var ev map[string]any + if json.Unmarshal([]byte(rest), &ev) == nil { + evs = append(evs, ev) + } + } + return evs +} + +func firstSnapshot(t *testing.T, out string) map[string]any { + t.Helper() + for _, ev := range sseData(t, out) { + if ev["type"] == "STATE_SNAPSHOT" { + return ev["snapshot"].(map[string]any) + } + } + t.Fatal("no STATE_SNAPSHOT found") + return nil +} + +// committedDeltas returns the JSON-patch op lists of every STATE_DELTA whose ops +// do NOT touch the /_predictive namespace (i.e. the committed deltas only). +func committedDeltas(t *testing.T, out string) [][]events.JSONPatchOperation { + t.Helper() + var committed [][]events.JSONPatchOperation + for _, ev := range sseData(t, out) { + if ev["type"] != "STATE_DELTA" { + continue + } + raw, _ := json.Marshal(ev["delta"]) + var ops []events.JSONPatchOperation + if json.Unmarshal(raw, &ops) != nil { + continue + } + predictive := false + for _, op := range ops { + if strings.HasPrefix(op.Path, predictiveDraftPath) { + predictive = true + break + } + } + if !predictive { + committed = append(committed, ops) + } + } + return committed +} diff --git a/sdks/community/go/example/server/internal/agent/reasoning_test.go b/sdks/community/go/example/server/internal/agent/reasoning_test.go new file mode 100644 index 0000000000..76969eba5f --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/reasoning_test.go @@ -0,0 +1,120 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "strings" + "testing" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" +) + +// fakeModel yields a fixed set of streamed chunks. It exists to exercise the +// REASONING_* emit path, which the live codex proof does not trigger (gpt-5.5 +// returns encrypted reasoning, not plaintext ReasoningContent). +type fakeModel struct{ chunks []*schema.Message } + +func (f *fakeModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) { + return schema.ConcatMessages(f.chunks) +} + +func (f *fakeModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) { + sr, sw := schema.Pipe[*schema.Message](len(f.chunks) + 1) + go func() { + defer sw.Close() + for _, c := range f.chunks { + sw.Send(c, nil) + } + }() + return sr, nil +} + +func (f *fakeModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { + return f, nil +} + +func TestStreamTurnEmitsReasoningThenText(t *testing.T) { + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), "t", "r", nil) + + fm := &fakeModel{chunks: []*schema.Message{ + {Role: schema.Assistant, ReasoningContent: "let me think"}, + {Role: schema.Assistant, Content: "Hello"}, + {Role: schema.Assistant, Content: " world"}, + }} + + msg, err := streamTurn(context.Background(), emit, fm, nil, false) + if err != nil { + t.Fatalf("streamTurn: %v", err) + } + _ = w.Flush() + out := buf.String() + + for _, want := range []string{ + `"type":"REASONING_START"`, + `"type":"REASONING_MESSAGE_START"`, + `"type":"REASONING_MESSAGE_CONTENT"`, + `"type":"REASONING_MESSAGE_END"`, + `"type":"REASONING_END"`, + `"type":"TEXT_MESSAGE_START"`, + `"type":"TEXT_MESSAGE_CONTENT"`, + `"type":"TEXT_MESSAGE_END"`, + } { + if !strings.Contains(out, want) { + t.Errorf("missing event %s", want) + } + } + if strings.Index(out, `"type":"REASONING_END"`) > strings.Index(out, `"type":"TEXT_MESSAGE_START"`) { + t.Errorf("reasoning block must close before text starts") + } + if msg.Content != "Hello world" { + t.Errorf("merged content = %q, want %q", msg.Content, "Hello world") + } +} + +// TestStreamTurnClosesTextBeforeInterleavedReasoning guards the symmetric block +// close: if a provider emits reasoning AFTER text has started, the open TEXT block +// must close before the REASONING block opens, so the two never overlap on the +// wire. Latent today (the live provider sends reasoning-before-text) but activates +// silently on a provider change. +func TestStreamTurnClosesTextBeforeInterleavedReasoning(t *testing.T) { + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), "t", "r", nil) + + fm := &fakeModel{chunks: []*schema.Message{ + {Role: schema.Assistant, Content: "first"}, + {Role: schema.Assistant, ReasoningContent: "rethink"}, + {Role: schema.Assistant, Content: "second"}, + }} + + if _, err := streamTurn(context.Background(), emit, fm, nil, false); err != nil { + t.Fatalf("streamTurn: %v", err) + } + _ = w.Flush() + out := buf.String() + + // The first TEXT block must close before the interleaved REASONING opens. + firstTextEnd := strings.Index(out, `"type":"TEXT_MESSAGE_END"`) + reasoningStart := strings.Index(out, `"type":"REASONING_START"`) + if firstTextEnd == -1 || reasoningStart == -1 { + t.Fatalf("expected both a TEXT_MESSAGE_END and a REASONING_START:\n%s", out) + } + if firstTextEnd > reasoningStart { + t.Errorf("the TEXT block must close before the interleaved REASONING opens:\n%s", out) + } + + // And REASONING must close before the second TEXT block opens. + reasoningEnd := strings.Index(out, `"type":"REASONING_END"`) + secondTextStart := strings.LastIndex(out, `"type":"TEXT_MESSAGE_START"`) + if reasoningEnd == -1 || secondTextStart == -1 { + t.Fatalf("expected a REASONING_END and a second TEXT_MESSAGE_START:\n%s", out) + } + if reasoningEnd > secondTextStart { + t.Errorf("REASONING must close before the second TEXT block opens:\n%s", out) + } +} diff --git a/sdks/community/go/example/server/internal/agent/runconfig.go b/sdks/community/go/example/server/internal/agent/runconfig.go new file mode 100644 index 0000000000..7ede1a8f22 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/runconfig.go @@ -0,0 +1,166 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/cloudwego/eino/schema" + "github.com/eino-contrib/jsonschema" +) + +// ToolPolicy selects how a route treats tools. +type ToolPolicy int + +const ( + // ServerOnly is today's /agentic behavior: only server-owned tools (file_read). + ServerOnly ToolPolicy = iota + // ClientTools accepts RunAgentInput.tools and hands client-defined calls back + // to the client (the server cannot execute them). + ClientTools +) + +// RunConfig parameterizes a single route's behavior over the shared agent loop. +// The zero value is the /agentic default (ServerOnly, lifecycle state, interrupt +// per AutoApprove, post-turn emitToolProposal). +type RunConfig struct { + // SystemPrompt is the route's posture. Empty uses the default file_read prompt. + SystemPrompt string + // ToolPolicy selects server-only vs client-defined tool handling. + ToolPolicy ToolPolicy + // ExposeFileRead binds the server file_read tool on this route. + ExposeFileRead bool + // NeverInterrupt skips the approval-interrupt branch entirely (feature routes + // can't resume — the Dart client has no resume path). + NeverInterrupt bool + // StreamToolCalls streams TOOL_CALL_* live from streamTurn instead of via the + // post-turn emitToolProposal. The two are mutually exclusive (never both, or + // every call double-emits). Only the tools track sets this. + StreamToolCalls bool +} + +// DefaultRunConfig is /agentic's behavior: unchanged from before the refactor. +func DefaultRunConfig() RunConfig { return RunConfig{} } + +// AgenticChatConfig is the /agentic_chat (frontend-tools) posture. +func AgenticChatConfig() RunConfig { + return RunConfig{ + SystemPrompt: agenticChatSystemPrompt, + ToolPolicy: ClientTools, + ExposeFileRead: false, + NeverInterrupt: true, + StreamToolCalls: true, + } +} + +const agenticChatSystemPrompt = "You are a helpful assistant. When a user request is best " + + "served by one of the available tools, call that tool with arguments that conform to its " + + "schema. You do not execute those tools yourself — you propose the call and the client " + + "fulfills it, then continues the conversation with the result. If no tool fits, answer " + + "directly and concisely." + +// ToolBasedGenerativeUIConfig is the /tool_based_generative_ui posture: prefer the +// generative-UI rendering tool over a prose answer for fitting prompts. It is 01's +// machinery (client tools + streaming tap) with a stronger prefer-the-tool prompt. +func ToolBasedGenerativeUIConfig() RunConfig { + cfg := AgenticChatConfig() + cfg.SystemPrompt = genUISystemPrompt + return cfg +} + +const genUISystemPrompt = "You are a generative-UI assistant. When the user's request can be " + + "presented through one of the provided rendering tools (for example a tool that renders " + + "structured content such as a card), you MUST call that tool with well-formed structured " + + "arguments that satisfy its schema, rather than answering in prose. Only answer in plain " + + "text when no provided tool fits the request." + +// HumanInTheLoopConfig is the /human_in_the_loop posture: route consequential +// actions through a client-defined approval tool and wait for the user's decision +// (carried back as a role:tool result on the follow-up run). It is 01's machinery +// with an approval-gating prompt. The per-request approval value ("off" disables +// the gate, serving plain agentic chat; anything else — including empty — keeps it +// on) comes from the X-AG-Approval header or the ?approval= query param. +func HumanInTheLoopConfig(approval string) RunConfig { + cfg := AgenticChatConfig() + if strings.EqualFold(approval, "off") { + return cfg // ungated: behave like /agentic_chat + } + cfg.SystemPrompt = humanInTheLoopSystemPrompt + return cfg +} + +const humanInTheLoopSystemPrompt = "You are a careful assistant. Before performing any " + + "consequential or irreversible action (deleting, sending, purchasing, or modifying data), " + + "you MUST first call the provided approval tool with a clear, human-readable summary of " + + "what you intend to do, and wait for the result. Proceed only after the user approves; if " + + "the user rejects, acknowledge it and do not perform the action. For non-consequential " + + "requests, answer directly." + +// clientToolInfos converts AG-UI client tool definitions (RunAgentInput.tools) +// into eino ToolInfos the model can be bound to. Errors (empty/duplicate names, +// unparseable parameter schemas) are surfaced as RUN_ERROR by the caller. +func clientToolInfos(tools []aguitypes.Tool) ([]*schema.ToolInfo, error) { + out := make([]*schema.ToolInfo, 0, len(tools)) + seen := make(map[string]bool, len(tools)) + for _, t := range tools { + if t.Name == "" { + return nil, fmt.Errorf("a client tool has an empty name") + } + if seen[t.Name] { + return nil, fmt.Errorf("duplicate client tool name %q", t.Name) + } + seen[t.Name] = true + + info := &schema.ToolInfo{Name: t.Name, Desc: t.Description} + js, err := toJSONSchema(t.Parameters) + if err != nil { + return nil, fmt.Errorf("tool %q parameters: %w", t.Name, err) + } + if js != nil { + info.ParamsOneOf = schema.NewParamsOneOfByJSONSchema(js) + } + out = append(out, info) + } + return out, nil +} + +// toJSONSchema converts an arbitrary client-supplied JSON Schema (the tool's +// `parameters`, decoded as `any`) into an eino *jsonschema.Schema. A nil/absent +// schema yields nil (a no-argument tool). +func toJSONSchema(params any) (*jsonschema.Schema, error) { + if params == nil { + return nil, nil + } + b, err := json.Marshal(params) + if err != nil { + return nil, err + } + if string(b) == "null" { + return nil, nil + } + var s jsonschema.Schema + if err := json.Unmarshal(b, &s); err != nil { + return nil, fmt.Errorf("not a valid JSON Schema: %w", err) + } + return &s, nil +} + +// classifyToolCalls splits actionable calls into client-defined (handed back to +// the client) and everything else (the server path). A name in clientNames is a +// client tool; any other name — a server-owned tool like file_read, or a +// hallucinated unknown name — goes to the server path, where settlePendingToolCalls +// runs it (or answers an unknown name with an "unknown tool" error the model can +// recover from). The server toolset isn't consulted here: known-server and unknown +// names are handled identically downstream, so distinguishing them would be a +// no-op. +func classifyToolCalls(calls []schema.ToolCall, clientNames map[string]bool) (server, client []schema.ToolCall) { + for _, tc := range calls { + if clientNames[tc.Function.Name] { + client = append(client, tc) + } else { + server = append(server, tc) + } + } + return server, client +} diff --git a/sdks/community/go/example/server/internal/agent/sharedstate.go b/sdks/community/go/example/server/internal/agent/sharedstate.go new file mode 100644 index 0000000000..bec1732318 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/sharedstate.go @@ -0,0 +1,225 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/cloudwego/eino/schema" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" +) + +// SharedState runs the /shared_state flow (request 04): a collaborative recipe +// document both the user and the agent edit. The client seeds the document in +// RunAgentInput.state.recipe; the agent mutates it in response to chat, surfacing +// each change as a granular RFC-6902 STATE_DELTA against /recipe/...; and a later +// run adopts the user-edited document verbatim (last-writer-wins). +// +// The agent edits via an internal tool whose TOOL_CALL_* events are deliberately +// SUPPRESSED and translated into STATE_DELTAs — the wire carries STATE_SNAPSHOT + +// STATE_DELTAs only, no tool-call events (matching request 04's contract). The +// edits are in-memory document state, not filesystem writes, so the read-only +// constraint is untouched. +type SharedState struct { + Deps *Deps +} + +const sharedStateSystemPrompt = "You are a collaborative recipe assistant. You and the user " + + "edit a shared recipe together. To change the recipe, call the apply_recipe_changes tool " + + "with only the fields you want to change; do not restate the whole recipe. After applying " + + "changes, briefly tell the user what you changed. If the user only asks a question, answer " + + "it without calling the tool." + +func (s SharedState) Run(ctx context.Context, emit *Emitter, in *aguitypes.RunAgentInput, threadID, runID string) { + emit.RunStarted() + + doc := seedRecipe(in.State) + emit.StateSnapshot(doc.Snapshot()) + + cm, err := s.Deps.BaseModel.WithTools([]*schema.ToolInfo{recipeEditTool()}) + if err != nil { + emit.RunError("failed to bind the recipe-edit tool") + return + } + + // Inject the current recipe into the model's context. The recipe is shared STATE, + // not part of the message history, so without this the model cannot see the title, + // servings, ingredients, or steps it is supposed to collaborate on (it would answer + // questions blind and could not compute edits like "double the servings"). + messages := ensureSystemPrompt(toEinoMessages(in.Messages, s.Deps.Provider), sharedStateSystemPrompt+currentRecipeContext(doc)) + + maxIter := s.Deps.MaxIterations + if maxIter <= 0 { + maxIter = config.DefaultMaxIterations + } + for iter := 0; iter < maxIter; iter++ { + if emit.Err() != nil || ctx.Err() != nil { + return + } + emit.StepStarted("llm") + assistant, err := streamTurn(ctx, emit, cm, messages, false) // tap off: edit-tool calls are suppressed + emit.StepFinished("llm") + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || emit.Err() != nil { + return + } + s.Deps.Logger.Error("shared_state turn failed", "thread", threadID, "run", runID, "error", err) + emit.RunError("the agent failed to generate a response") + return + } + messages = append(messages, assistant) + + // Quiet validation: this route's contract is STATE_* only, no tool-call + // events — so a malformed call must not leak a TOOL_CALL_RESULT. + actionable := validateToolCallsQuiet(s.Deps.Logger, assistant, &messages) + if len(assistant.ToolCalls) == 0 { + // Final text answer. + emit.MessagesSnapshot(toAGUIMessages(messages)) + emit.RunFinishedSuccess() + return + } + if len(actionable) == 0 { + continue + } + // Translate each edit-tool call into granular STATE_DELTAs (no TOOL_CALL_*), + // thread a tool result back so the model can continue. + for _, tc := range actionable { + result := applyRecipeChanges(emit, doc, tc) + messages = append(messages, schema.ToolMessage(result, tc.ID)) + } + } + + emit.MessagesSnapshot(toAGUIMessages(messages)) + emit.RunError(fmt.Sprintf("agent did not converge within %d iterations", maxIter)) +} + +// currentRecipeContext renders the current recipe as a system-prompt suffix so the +// model can see the shared state. Returns "" if the document can't be marshaled. +func currentRecipeContext(doc *DocState) string { + b, err := json.Marshal(doc.Snapshot()) + if err != nil { + return "" + } + return "\n\nThe current shared recipe is:\n" + string(b) + + "\nUse these exact values when answering questions or computing edits " + + "(e.g. when doubling servings or removing an ingredient by position)." +} + +// seedRecipe builds the working document from client-seeded state. It adopts +// state.recipe verbatim (so a user-edited document round-trips), or starts from a +// small default recipe when none is provided. The document is exactly {recipe:...}. +func seedRecipe(state any) *DocState { + if m, ok := state.(map[string]any); ok { + if r, has := m["recipe"]; has { + return NewDocState(map[string]any{"recipe": r}) + } + } + return NewDocState(map[string]any{"recipe": map[string]any{ + "title": "Tomato Pasta", + "servings": float64(2), + "ingredients": []any{map[string]any{"name": "pasta", "amount": "200g"}}, + "steps": []any{"Boil pasta."}, + }}) +} + +// recipeEditTool is the internal edit tool. Its call is intercepted (never executed +// as a normal tool, never surfaced as TOOL_CALL_*) and translated to STATE_DELTAs. +func recipeEditTool() *schema.ToolInfo { + js, _ := toJSONSchema(map[string]any{ + "type": "object", + "properties": map[string]any{ + "title": map[string]any{"type": "string", "description": "New recipe title"}, + "servings": map[string]any{"type": "integer", "description": "New servings count"}, + "add_ingredients": map[string]any{"type": "array", "items": map[string]any{ + "type": "object", + "properties": map[string]any{"name": map[string]any{"type": "string"}, "amount": map[string]any{"type": "string"}}, + "required": []any{"name"}, + }}, + "remove_ingredient_indices": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, + "description": "Zero-based indices of ingredients to remove"}, + "add_steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + }) + info := &schema.ToolInfo{ + Name: "apply_recipe_changes", + Desc: "Apply edits to the shared recipe. Provide only the fields you want to change.", + } + if js != nil { + info.ParamsOneOf = schema.NewParamsOneOfByJSONSchema(js) + } + return info +} + +type recipeChanges struct { + Title *string `json:"title"` + Servings *json.Number `json:"servings"` // json.Number so 4 and 4.0 both parse (LLMs emit trailing .0) + AddIngredients []struct { + Name string `json:"name"` + Amount string `json:"amount"` + } `json:"add_ingredients"` + RemoveIngredientIndices []int `json:"remove_ingredient_indices"` + AddSteps []string `json:"add_steps"` +} + +// applyRecipeChanges parses the edit-tool arguments and emits one granular +// STATE_DELTA per logical change, keeping DocState consistent. Each op is applied +// independently; an op that fails to apply is skipped and reported (never a panic). +// Returns a short JSON result threaded back to the model. +func applyRecipeChanges(emit *Emitter, doc *DocState, tc schema.ToolCall) string { + var ch recipeChanges + if err := json.Unmarshal([]byte(tc.Function.Arguments), &ch); err != nil { + return `{"error":"could not parse recipe changes"}` + } + + applied, skipped := 0, 0 + apply := func(op events.JSONPatchOperation) { + if err := doc.Apply([]events.JSONPatchOperation{op}); err != nil { + skipped++ // an op that doesn't apply (e.g. out-of-range remove) is reported, not fatal + return + } + emit.StateDelta([]events.JSONPatchOperation{op}) + applied++ + } + + if ch.Title != nil { + apply(events.JSONPatchOperation{Op: "replace", Path: "/recipe/title", Value: *ch.Title}) + } + if ch.Servings != nil { + // Accept any JSON number; the document carries servings as a number, and a + // model may emit 4 or 4.0. A non-numeric value is skipped, not fatal. + if n, err := ch.Servings.Float64(); err == nil { + apply(events.JSONPatchOperation{Op: "replace", Path: "/recipe/servings", Value: int(n)}) + } else { + skipped++ + } + } + for _, ing := range ch.AddIngredients { + v := map[string]any{"name": ing.Name} + if ing.Amount != "" { + v["amount"] = ing.Amount + } + apply(events.JSONPatchOperation{Op: "add", Path: "/recipe/ingredients/-", Value: v}) + } + // Remove highest indices first so earlier removals don't shift later ones. + idxs := append([]int(nil), ch.RemoveIngredientIndices...) + sort.Sort(sort.Reverse(sort.IntSlice(idxs))) + for _, i := range idxs { + apply(events.JSONPatchOperation{Op: "remove", Path: fmt.Sprintf("/recipe/ingredients/%d", i)}) + } + for _, st := range ch.AddSteps { + apply(events.JSONPatchOperation{Op: "add", Path: "/recipe/steps/-", Value: st}) + } + + // Report skipped ops so the model can tell the user a change was rejected + // (e.g. "ingredient index 9 didn't exist") rather than silently swallowing it. + // Include the updated recipe so a follow-up edit in the same run sees current state. + if b, err := json.Marshal(doc.Snapshot()); err == nil { + return fmt.Sprintf(`{"applied":%d,"skipped":%d,"recipe":%s}`, applied, skipped, string(b)) + } + return fmt.Sprintf(`{"applied":%d,"skipped":%d}`, applied, skipped) +} diff --git a/sdks/community/go/example/server/internal/agent/sharedstate_test.go b/sdks/community/go/example/server/internal/agent/sharedstate_test.go new file mode 100644 index 0000000000..51beda50ab --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/sharedstate_test.go @@ -0,0 +1,120 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "io" + "log/slog" + "strings" + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/schema" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" +) + +func runSharedState(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput) string { + t.Helper() + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) + tools, err := NewReadOnlyToolset(t.TempDir()) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + deps := &Deps{ + Model: cm, BaseModel: cm, Tools: tools, + Store: runstore.New(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), MaxIterations: 8, + } + SharedState{Deps: deps}.Run(context.Background(), emit, in, in.ThreadID, in.RunID) + _ = w.Flush() + return buf.String() +} + +func seededRecipeState() map[string]any { + return map[string]any{"recipe": map[string]any{ + "title": "Tomato Pasta", + "servings": float64(2), + "ingredients": []any{map[string]any{"name": "pasta", "amount": "200g"}}, + "steps": []any{"Boil pasta."}, + }} +} + +func TestSharedState_GranularDeltasNoToolEvents(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("c1", "apply_recipe_changes", + `{"add_ingredients":[{"name":"garlic","amount":"3 cloves"}],"servings":4}`)}, + {textChunk("Added garlic and scaled it to 4 servings.")}, + }} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Add garlic and make it serve 4."}}, + State: seededRecipeState(), + } + out := runSharedState(t, m, in) + + // Initial snapshot of the seeded document. + if !strings.Contains(out, `"type":"STATE_SNAPSHOT"`) || !strings.Contains(out, `"Tomato Pasta"`) { + t.Errorf("expected an initial STATE_SNAPSHOT of the recipe:\n%s", out) + } + // Granular deltas against /recipe/... + if !strings.Contains(out, `"path":"/recipe/ingredients/-"`) || !strings.Contains(out, `"garlic"`) { + t.Errorf("expected an add-ingredient delta:\n%s", out) + } + if !strings.Contains(out, `"path":"/recipe/servings"`) || !strings.Contains(out, `"value":4`) { + t.Errorf("expected a servings replace delta:\n%s", out) + } + // No tool-call events on the wire (request 04 contract). + for _, banned := range []string{`"type":"TOOL_CALL_START"`, `"type":"TOOL_CALL_ARGS"`, `"type":"TOOL_CALL_END"`, `"type":"TOOL_CALL_RESULT"`} { + if strings.Contains(out, banned) { + t.Errorf("shared_state must not emit %s:\n%s", banned, out) + } + } + // Concludes with text + clean finish. + if !strings.Contains(out, "Added garlic") || !strings.Contains(out, `"type":"RUN_FINISHED"`) { + t.Errorf("expected a summary and RUN_FINISHED:\n%s", out) + } + if strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Errorf("unexpected RUN_ERROR:\n%s", out) + } +} + +func TestSharedState_AdoptsUserEditedState(t *testing.T) { + // The user already edited the recipe (servings 6, basil added); the agent must + // build on that version, not clobber it. + userEdited := map[string]any{"recipe": map[string]any{ + "title": "Tomato Pasta", + "servings": float64(6), + "ingredients": []any{map[string]any{"name": "pasta", "amount": "200g"}, map[string]any{"name": "basil"}}, + "steps": []any{"Boil pasta."}, + }} + m := &scriptedModel{turns: [][]*schema.Message{{textChunk("It already serves 6 with basil.")}}} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r2", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "What's in it now?"}}, + State: userEdited, + } + out := runSharedState(t, m, in) + if !strings.Contains(out, `"basil"`) || !strings.Contains(out, `"servings":6`) { + t.Errorf("the snapshot must reflect the user-edited document:\n%s", out) + } +} + +func TestSharedState_RemoveIngredient(t *testing.T) { + m := &scriptedModel{turns: [][]*schema.Message{ + {toolCallChunk("c1", "apply_recipe_changes", `{"remove_ingredient_indices":[0]}`)}, + {textChunk("Removed the pasta.")}, + }} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Remove the pasta."}}, + State: seededRecipeState(), + } + out := runSharedState(t, m, in) + if !strings.Contains(out, `"op":"remove"`) || !strings.Contains(out, `"path":"/recipe/ingredients/0"`) { + t.Errorf("expected a remove delta:\n%s", out) + } +} diff --git a/sdks/community/go/example/server/internal/agent/state.go b/sdks/community/go/example/server/internal/agent/state.go new file mode 100644 index 0000000000..88f07e4dd2 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/state.go @@ -0,0 +1,130 @@ +package agent + +import ( + "encoding/json" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" +) + +// State is the agent's shared state, surfaced to the UI via AG-UI STATE_SNAPSHOT +// and incremental STATE_DELTA (RFC-6902 JSON Patch) events. +type State struct { + Status string + FilesRead []string + ToolCalls int + extra map[string]any // arbitrary client-seeded state +} + +// NewState returns a fresh running state. +func NewState() *State { + return &State{Status: "running", FilesRead: []string{}, extra: map[string]any{}} +} + +// StateFromSnapshot rebuilds a State from a persisted snapshot (used on resume). +// The keys "status", "filesRead", and "toolCalls" are reserved for the agent +// lifecycle; client-seeded values under those keys are coerced into the typed +// fields, not preserved as extra. +func StateFromSnapshot(m map[string]any) *State { + s := NewState() + if m == nil { + return s + } + for k, v := range m { + switch k { + case "status": + if str, ok := v.(string); ok { + s.Status = str + } + case "filesRead": + s.FilesRead = toStringSlice(v) + case "toolCalls": + s.ToolCalls = toInt(v) + default: + s.extra[k] = v + } + } + return s +} + +// Seed merges client-supplied state (from RunAgentInput.State) into extra keys. +func (s *State) Seed(v any) { + if m, ok := v.(map[string]any); ok { + for k, val := range m { + s.extra[k] = val + } + } +} + +// Snapshot returns the full state as a plain map for STATE_SNAPSHOT / persistence. +// FilesRead is copied so the returned map (which may be stored in runstore) does +// not alias the live backing slice. extra values are copied shallowly — fine today +// because nothing mutates a seeded value after Seed; if extra ever becomes mutable, +// deep-copy here to avoid aliasing the snapshot held by the SSE encoder and runstore. +func (s *State) Snapshot() map[string]any { + m := map[string]any{} + for k, v := range s.extra { + m[k] = v + } + files := make([]string, len(s.FilesRead)) + copy(files, s.FilesRead) + m["status"] = s.Status + m["filesRead"] = files + m["toolCalls"] = s.ToolCalls + return m +} + +// RecordFileRead mutates state for a completed file read and returns the JSON +// Patch describing the change (for a STATE_DELTA event). filesRead is an append-log, +// not a set: the same path read twice appears twice (mirroring the toolCalls count). +func (s *State) RecordFileRead(path string) []events.JSONPatchOperation { + s.ToolCalls++ + s.FilesRead = append(s.FilesRead, path) + s.Status = "reading" + return []events.JSONPatchOperation{ + {Op: "replace", Path: "/status", Value: "reading"}, + {Op: "replace", Path: "/toolCalls", Value: s.ToolCalls}, + {Op: "add", Path: "/filesRead/-", Value: path}, + } +} + +// SetStatus mutates the status and returns the JSON Patch for it. +func (s *State) SetStatus(status string) []events.JSONPatchOperation { + s.Status = status + return []events.JSONPatchOperation{{Op: "replace", Path: "/status", Value: status}} +} + +// toStringSlice and toInt are intentionally lenient: a client that seeds a reserved +// key (status/filesRead/toolCalls) with the wrong JSON type gets a zero value rather +// than an error, since the agent owns those keys and overwrites them anyway. +func toStringSlice(v any) []string { + switch t := v.(type) { + case []string: + // Copy so a caller-owned slice can't be mutated by a later RecordFileRead append. + out := make([]string, len(t)) + copy(out, t) + return out + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + } + return []string{} +} + +func toInt(v any) int { + switch t := v.(type) { + case int: + return t + case float64: + return int(t) + case json.Number: + if n, err := t.Int64(); err == nil { + return int(n) + } + } + return 0 +} diff --git a/sdks/community/go/example/server/internal/agent/tools.go b/sdks/community/go/example/server/internal/agent/tools.go new file mode 100644 index 0000000000..da81cdb9ab --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/tools.go @@ -0,0 +1,132 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" +) + +// Toolset is the agent's tool registry. +// +// NO-FILE-WRITE POLICY: this agent must never modify the filesystem. Only the +// read-only local fileReadTool ("file_read") is registered. Do NOT add +// write/edit/shell tools here — the whole point of this server is a read-only +// agent. The workspace root further sandboxes reads to a single directory. +type Toolset struct { + infos []*schema.ToolInfo + byName map[string]tool.InvokableTool +} + +// NewReadOnlyToolset builds a registry containing only file_read, rooted at the +// given absolute workspace directory. +func NewReadOnlyToolset(workspace string) (*Toolset, error) { + rt, err := newFileReadTool(workspace) + if err != nil { + return nil, fmt.Errorf("file_read tool: %w", err) + } + info, err := rt.Info(context.Background()) + if err != nil { + return nil, fmt.Errorf("file_read info: %w", err) + } + return &Toolset{ + infos: []*schema.ToolInfo{info}, + byName: map[string]tool.InvokableTool{info.Name: rt}, + }, nil +} + +// Infos returns the tool schemas to bind to the chat model. +func (t *Toolset) Infos() []*schema.ToolInfo { return t.infos } + +// Run executes a registered tool by name with JSON arguments. +func (t *Toolset) Run(ctx context.Context, name, argsJSON string) (string, error) { + tl, ok := t.byName[name] + if !ok { + return "", fmt.Errorf("unknown or unpermitted tool %q (this agent only allows file_read)", name) + } + return tl.InvokableRun(ctx, argsJSON) +} + +type fileReadTool struct { + root string + info *schema.ToolInfo +} + +type fileReadArgs struct { + Path string `json:"path"` +} + +func newFileReadTool(workspace string) (*fileReadTool, error) { + if workspace == "" { + workspace = "." + } + abs, err := filepath.Abs(workspace) + if err != nil { + return nil, err + } + root, err := filepath.EvalSymlinks(abs) + if err != nil { + return nil, err + } + return &fileReadTool{ + root: root, + info: &schema.ToolInfo{ + Name: "file_read", + Desc: "Read a UTF-8 text file from the configured read-only workspace. Provide a relative path within the workspace.", + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "path": { + Type: schema.String, + Desc: "Relative path to read from the configured workspace.", + Required: true, + }, + }), + }, + }, nil +} + +func (t *fileReadTool) Info(_ context.Context) (*schema.ToolInfo, error) { + return t.info, nil +} + +func (t *fileReadTool) InvokableRun(_ context.Context, argsJSON string, _ ...tool.Option) (string, error) { + var args fileReadArgs + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", fmt.Errorf("invalid file_read arguments: %w", err) + } + if strings.TrimSpace(args.Path) == "" { + return "", fmt.Errorf("path is required") + } + if filepath.IsAbs(args.Path) { + return "", fmt.Errorf("path must be relative to the configured workspace") + } + + target := filepath.Join(t.root, filepath.Clean(args.Path)) + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + return "", err + } + rel, err := filepath.Rel(t.root, resolved) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes the configured workspace") + } + info, err := os.Stat(resolved) + if err != nil { + return "", err + } + if info.IsDir() { + return "", fmt.Errorf("path is a directory") + } + b, err := os.ReadFile(resolved) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/sdks/community/go/example/server/internal/agent/tools_test.go b/sdks/community/go/example/server/internal/agent/tools_test.go new file mode 100644 index 0000000000..cd3841f2a2 --- /dev/null +++ b/sdks/community/go/example/server/internal/agent/tools_test.go @@ -0,0 +1,57 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestToolsetReadOnly is the guard for the core safety property of this server: +// the agent may read files but must never have a tool that mutates the +// filesystem. If someone registers a write/edit/shell tool, this fails. +func TestToolsetReadOnly(t *testing.T) { + ts, err := NewReadOnlyToolset(t.TempDir()) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + + infos := ts.Infos() + if len(infos) != 1 { + t.Fatalf("expected exactly 1 tool, got %d", len(infos)) + } + if infos[0].Name != "file_read" { + t.Fatalf("expected only %q, got %q", "file_read", infos[0].Name) + } + + // Any non-read tool must be rejected by the dispatcher. + for _, name := range []string{"file_write", "file_edit", "shell", "tracker_write"} { + if _, err := ts.Run(context.Background(), name, `{}`); err == nil { + t.Fatalf("tool %q should not be runnable on a read-only agent", name) + } else if !strings.Contains(err.Error(), "only allows file_read") { + t.Fatalf("unexpected error for %q: %v", name, err) + } + } +} + +func TestFileReadToolReadsOnlyInsideWorkspace(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "note.txt"), []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + ts, err := NewReadOnlyToolset(root) + if err != nil { + t.Fatalf("NewReadOnlyToolset: %v", err) + } + out, err := ts.Run(context.Background(), "file_read", `{"path":"note.txt"}`) + if err != nil { + t.Fatalf("file_read: %v", err) + } + if out != "hello" { + t.Fatalf("file_read output = %q, want hello", out) + } + if _, err := ts.Run(context.Background(), "file_read", `{"path":"../outside.txt"}`); err == nil { + t.Fatal("expected path traversal to be rejected") + } +} diff --git a/sdks/community/go/example/server/internal/agentic/agentic.go b/sdks/community/go/example/server/internal/agentic/agentic.go deleted file mode 100644 index 9a4ae1ac62..0000000000 --- a/sdks/community/go/example/server/internal/agentic/agentic.go +++ /dev/null @@ -1,99 +0,0 @@ -package agentic - -import ( - "bufio" - "context" - _ "embed" - "fmt" - - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/mcp" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" - "github.com/tmc/langchaingo/agents" - "github.com/tmc/langchaingo/chains" - "github.com/tmc/langchaingo/llms/anthropic" - "golang.org/x/sync/errgroup" - - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" - langchaingoTools "github.com/tmc/langchaingo/tools" -) - -// reminder is a reminder for the AI to output in our expected format. -// -//go:embed data/reminder.md -var reminder string - -func CallLLM(ctx context.Context, input string, tools []langchaingoTools.Tool, returnChan chan<- string) error { - // adapter for the mcp server defined in sdks/community/go/example/server/internal/mcp - // this mcp server starts with the Fiber server in sdks/community/go/example/server/cmd/main.go - adapter, err := mcp.NewAdapter(fmt.Sprintf("http://127.0.0.1:%d/mcp", mcp.DefaultPort)) - - if err != nil { - return fmt.Errorf("new mcp adapter: %w", err) - } - - _, err = adapter.Tools() - if err != nil { - return fmt.Errorf("append tools: %w", err) - } - - llm, err := anthropic.New(anthropic.WithModel("claude-3-haiku-20240307")) - if err != nil { - return fmt.Errorf("failed to create LLM client: %w", err) - } - - agent := agents.NewOneShotAgent(llm, - tools, - agents.WithMaxIterations(50)) - - executor := agents.NewExecutor(agent, agents.WithCallbacksHandler(NewHandler(returnChan))) - - inputMap := make(map[string]any) - inputMap["input"] = input + "\n" + reminder - - result, err := chains.Call(ctx, executor, inputMap) - if err != nil { - return fmt.Errorf("run chain: %w", err) - } - output := result["output"].(string) - - // Create a proper event for the final output - messageID := events.GenerateMessageID() - finalMessage := events.NewTextMessageContentEvent(messageID, output) - if jsonData, err := finalMessage.ToJSON(); err == nil { - returnChan <- string(jsonData) - } - - return nil -} - -func ProcessInput(ctx context.Context, w *bufio.Writer, sseWriter *sse.SSEWriter, input string) error { - resultChan := make(chan string) - g, groupCtx := errgroup.WithContext(ctx) - - g.Go(func() error { - for { - select { - case result := <-resultChan: - if result == "" { - return nil - } - - // All messages from the handler should now be proper JSON events - // WriteBytes will format them as SSE frames with "data: " prefix - if err := sseWriter.WriteBytes(ctx, w, []byte(result)); err != nil { - return fmt.Errorf("failed to write event: %w", err) - } - case <-ctx.Done(): - return ctx.Err() - } - } - }) - - g.Go(func() error { - callLLMErr := CallLLM(groupCtx, input, nil, resultChan) - close(resultChan) - return callLLMErr - }) - - return g.Wait() -} diff --git a/sdks/community/go/example/server/internal/agentic/agentic_integration_test.go b/sdks/community/go/example/server/internal/agentic/agentic_integration_test.go deleted file mode 100644 index a464952c57..0000000000 --- a/sdks/community/go/example/server/internal/agentic/agentic_integration_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package agentic - -import ( - "context" - _ "embed" - "os" - "testing" - - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/mcp" - "github.com/stretchr/testify/require" - "golang.org/x/sync/errgroup" -) - -//go:embed data/client_prompt.md -var client_prompt string - -//go:embed data/languages_prompt.md -var languages_prompt string - -// Verifies agent communication is working and it can call tools we pass to it -func TestToolCalls(t *testing.T) { - if os.Getenv("RUN_INTEGRATION_TESTS") != "true" { - t.Skip("Skipping integration test") - } - mcpServer, err := mcp.NewServer(mcp.DefaultPort) - require.NoError(t, err) - go func() { - mcpErr := mcpServer.Start() - if mcpErr != nil { - require.NoError(t, mcpErr) - } - }() - - ctx := context.Background() - resultChan := make(chan string) - g, groupCtx := errgroup.WithContext(ctx) - var results []string - - g.Go(func() error { - for { - select { - case result := <-resultChan: - if result == "" { - return nil - } - results = append(results, result) - case <-groupCtx.Done(): - return groupCtx.Err() - case <-ctx.Done(): - return ctx.Err() - } - } - }) - - g.Go(func() error { - callErr := CallLLM(groupCtx, languages_prompt, nil, resultChan) - close(resultChan) - return callErr - }) - - if err = g.Wait(); err != nil { - require.NoError(t, err) - } - require.NoError(t, err) - require.NotEmpty(t, results) -} diff --git a/sdks/community/go/example/server/internal/agentic/data/languages_prompt.md b/sdks/community/go/example/server/internal/agentic/data/languages_prompt.md deleted file mode 100644 index 1f1cc6fb9c..0000000000 --- a/sdks/community/go/example/server/internal/agentic/data/languages_prompt.md +++ /dev/null @@ -1,21 +0,0 @@ -# Get Available Languages Prompt - -## Agent Instructions - -The AG-UI README.md contains a list of Language SDKs. You are to look at "Programming Languages peoples are learning" and create a list of 4 possible programming languages we could add support for. These need to be programming languages with full support for server sent events when running on the command line. So HTML or CSS are not appropriate languages but something like C++ or Elixir could be. We then want to use the provide_language_options tool to create a report of these. - -## Project Information -Read through relevant documentation and create a list of 4 programming languages we could add support for. - -### Links to Relevant Documentation -AG-UI Docs -https://docs.ag-ui.com/llms-full.txt - -AG-UI README.md -https://github.com/ag-ui-protocol/ag-ui/blob/main/README.md - -Programming Languages peoples are learning -https://survey.stackoverflow.co/2025/technology#most-popular-technologies-language-learn - -### Specific Requirements -provide_language_options tool is called with 4 options diff --git a/sdks/community/go/example/server/internal/agentic/data/reminder.md b/sdks/community/go/example/server/internal/agentic/data/reminder.md deleted file mode 100644 index 14fcdf2e19..0000000000 --- a/sdks/community/go/example/server/internal/agentic/data/reminder.md +++ /dev/null @@ -1,2 +0,0 @@ -## MOST IMPORTANT REMINDER -Your final output MUST start with the string "Final Answer: " \ No newline at end of file diff --git a/sdks/community/go/example/server/internal/agentic/handler.go b/sdks/community/go/example/server/internal/agentic/handler.go deleted file mode 100644 index 84ec542446..0000000000 --- a/sdks/community/go/example/server/internal/agentic/handler.go +++ /dev/null @@ -1,308 +0,0 @@ -package agentic - -import ( - "context" - "fmt" - - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" - "github.com/tmc/langchaingo/llms" - "github.com/tmc/langchaingo/schema" -) - -type Handler struct { - returnChan chan<- string - // ID tracking for event correlation - threadID string - runID string - messageID string - toolCallID string - stepID string -} - -func NewHandler(returnChan chan<- string) *Handler { - return &Handler{ - returnChan: returnChan, - threadID: events.GenerateThreadID(), - runID: events.GenerateRunID(), - } -} - -func (h *Handler) HandleText(ctx context.Context, text string) { - message := events.NewTextMessageContentEvent("test", text) - if jsonData, err := message.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleLLMStart(ctx context.Context, prompts []string) { - // Generate new message ID for this LLM interaction - h.messageID = events.GenerateMessageID() - - // Send run started event - runStartedEvent := events.NewRunStartedEvent(h.threadID, h.runID) - if jsonData, err := runStartedEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // Send text message start event - textStartEvent := events.NewTextMessageStartEvent(h.messageID, events.WithRole("assistant")) - if jsonData, err := textStartEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleLLMGenerateContentStart(ctx context.Context, ms []llms.MessageContent) { - // Generate new message ID if not already set - if h.messageID == "" { - h.messageID = events.GenerateMessageID() - } - - // Determine role from message content - role := "assistant" - if len(ms) > 0 { - // Check if this is from a tool or user - for _, m := range ms { - if len(m.Parts) > 0 { - if _, ok := m.Parts[0].(llms.ToolCallResponse); ok { - role = "tool" - break - } - } - } - } - - // Send text message start event - textStartEvent := events.NewTextMessageStartEvent(h.messageID, events.WithRole(role)) - if jsonData, err := textStartEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleLLMGenerateContentEnd(ctx context.Context, res *llms.ContentResponse) { - // Send text message end event - if h.messageID != "" { - textEndEvent := events.NewTextMessageEndEvent(h.messageID) - if jsonData, err := textEndEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - } - - // Reset message ID for next interaction - h.messageID = "" -} - -func (h *Handler) HandleLLMError(ctx context.Context, err error) { - // Send error as text message if we have an active message - if h.messageID != "" { - errorMessage := events.NewTextMessageContentEvent(h.messageID, "Error: "+err.Error()) - if jsonData, err := errorMessage.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // End the message - textEndEvent := events.NewTextMessageEndEvent(h.messageID) - if jsonData, err := textEndEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - h.messageID = "" - } -} - -func (h *Handler) HandleChainStart(ctx context.Context, inputs map[string]any) { - // Generate step ID for this chain execution - h.stepID = events.GenerateStepID() - - // Send step started event with step name - stepStartedEvent := events.NewStepStartedEvent(h.stepID) - if jsonData, err := stepStartedEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleChainEnd(ctx context.Context, outputs map[string]any) { - // Send step finished event - if h.stepID != "" { - stepFinishedEvent := events.NewStepFinishedEvent(h.stepID) - if jsonData, err := stepFinishedEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - h.stepID = "" - } -} - -func (h *Handler) HandleChainError(ctx context.Context, err error) { - // Send error event for chain - if h.stepID != "" { - // Create error message - if h.messageID == "" { - h.messageID = events.GenerateMessageID() - } - errorMessage := events.NewTextMessageContentEvent(h.messageID, "Chain error: "+err.Error()) - if jsonData, err := errorMessage.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // Mark step as finished even with error - stepFinishedEvent := events.NewStepFinishedEvent(h.stepID) - if jsonData, err := stepFinishedEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - h.stepID = "" - } -} - -func (h *Handler) HandleToolStart(ctx context.Context, input string) { - // Generate tool call ID - h.toolCallID = events.GenerateToolCallID() - - // Extract tool name from input if possible - toolName := "tool" - - // Send tool call start event - toolStartEvent := events.NewToolCallStartEvent(h.toolCallID, toolName) - if h.messageID != "" { - toolStartEvent = events.NewToolCallStartEvent(h.toolCallID, toolName, events.WithParentMessageID(h.messageID)) - } - if jsonData, err := toolStartEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // Send tool arguments - toolArgsEvent := events.NewToolCallArgsEvent(h.toolCallID, input) - if jsonData, err := toolArgsEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleToolEnd(ctx context.Context, output string) { - if h.toolCallID != "" { - // Send tool call end event - toolEndEvent := events.NewToolCallEndEvent(h.toolCallID) - if jsonData, err := toolEndEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // Send tool call result event - resultMessageID := events.GenerateMessageID() - toolResultEvent := events.NewToolCallResultEvent(resultMessageID, h.toolCallID, output) - if jsonData, err := toolResultEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - h.toolCallID = "" - } -} - -func (h *Handler) HandleToolError(ctx context.Context, err error) { - if h.toolCallID != "" { - // Send error as tool result - resultMessageID := events.GenerateMessageID() - toolResultEvent := events.NewToolCallResultEvent(resultMessageID, h.toolCallID, "Error: "+err.Error()) - if jsonData, err := toolResultEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // Send tool call end event - toolEndEvent := events.NewToolCallEndEvent(h.toolCallID) - if jsonData, err := toolEndEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - h.toolCallID = "" - } -} - -func (h *Handler) HandleAgentAction(ctx context.Context, action schema.AgentAction) { - // Agent is taking an action (usually a tool call) - // Generate tool call ID if not already set - if h.toolCallID == "" { - h.toolCallID = events.GenerateToolCallID() - } - - // Send tool call start event for the action - toolStartEvent := events.NewToolCallStartEvent(h.toolCallID, action.Tool) - if h.messageID != "" { - toolStartEvent = events.NewToolCallStartEvent(h.toolCallID, action.Tool, events.WithParentMessageID(h.messageID)) - } - if jsonData, err := toolStartEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // Send the tool input as arguments - toolArgsEvent := events.NewToolCallArgsEvent(h.toolCallID, action.ToolInput) - if jsonData, err := toolArgsEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleAgentFinish(ctx context.Context, finish schema.AgentFinish) { - // Agent has finished its run - // Send final message if we have output - if finish.ReturnValues != nil { - if output, ok := finish.ReturnValues["output"].(string); ok && output != "" { - if h.messageID == "" { - h.messageID = events.GenerateMessageID() - } - finalMessage := events.NewTextMessageContentEvent(h.messageID, output) - if jsonData, err := finalMessage.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - - // End the message - textEndEvent := events.NewTextMessageEndEvent(h.messageID) - if jsonData, err := textEndEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - } - } - - // Send run finished event - runFinishedEvent := events.NewRunFinishedEvent(h.threadID, h.runID) - if jsonData, err := runFinishedEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleRetrieverStart(ctx context.Context, query string) { - // Start a retrieval operation - if h.messageID == "" { - h.messageID = events.GenerateMessageID() - } - - // Send a message indicating retrieval is starting - retrievalMessage := events.NewTextMessageContentEvent(h.messageID, "Searching for: "+query) - if jsonData, err := retrievalMessage.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} - -func (h *Handler) HandleRetrieverEnd(ctx context.Context, query string, documents []schema.Document) { - // Retrieval operation completed - if h.messageID != "" { - // Send message about retrieval results - resultMsg := fmt.Sprintf("Found %d documents for query: %s", len(documents), query) - retrievalResult := events.NewTextMessageContentEvent(h.messageID, resultMsg) - if jsonData, err := retrievalResult.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - } -} - -func (h *Handler) HandleStreamingFunc(ctx context.Context, chunk []byte) { - // Handle streaming content chunks - if h.messageID == "" { - h.messageID = events.GenerateMessageID() - // Send start event for streaming - textStartEvent := events.NewTextMessageStartEvent(h.messageID, events.WithRole("assistant")) - if jsonData, err := textStartEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } - } - - // Send the chunk as content - chunkStr := string(chunk) - contentEvent := events.NewTextMessageContentEvent(h.messageID, chunkStr) - if jsonData, err := contentEvent.ToJSON(); err == nil { - h.returnChan <- string(jsonData) - } -} diff --git a/sdks/community/go/example/server/internal/audio/client.go b/sdks/community/go/example/server/internal/audio/client.go new file mode 100644 index 0000000000..4f1d6ce350 --- /dev/null +++ b/sdks/community/go/example/server/internal/audio/client.go @@ -0,0 +1,140 @@ +package audio + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "strings" + "time" +) + +const defaultModel = "whisper-1" + +type TranscribeRequest struct { + AudioBase64 string // bare base64, no data-URL prefix + MimeType string // e.g. "audio/wav" + Model string // overrides env default +} + +type TranscribeResult struct { + Text string +} + +func Transcribe(ctx context.Context, req TranscribeRequest) (*TranscribeResult, error) { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("OPENAI_API_KEY is not set") + } + model := req.Model + if model == "" { + model = os.Getenv("AUDIO_MODEL") + } + if model == "" { + model = defaultModel + } + + audioBytes, err := base64.StdEncoding.DecodeString(req.AudioBase64) + if err != nil { + return nil, fmt.Errorf("base64 decode failed: %w", err) + } + + ext := mimeToExt(req.MimeType) + filename := "audio" + ext + contentType := req.MimeType + if contentType == "" { + contentType = "audio/wav" + } + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + if err := mw.WriteField("model", model); err != nil { + return nil, fmt.Errorf("building multipart body: %w", err) + } + + // "file" part — Content-Disposition must include a filename; Whisper infers format from it. + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename)) + h.Set("Content-Type", contentType) + pw, err := mw.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("creating file part: %w", err) + } + if _, err := io.Copy(pw, bytes.NewReader(audioBytes)); err != nil { + return nil, fmt.Errorf("writing audio bytes: %w", err) + } + mw.Close() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://api.openai.com/v1/audio/transcriptions", + &buf) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", mw.FormDataContentType()) + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("Whisper request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var errPayload struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&errPayload) + if errPayload.Error != nil { + return nil, fmt.Errorf("Whisper error (HTTP %d): %s", resp.StatusCode, errPayload.Error.Message) + } + return nil, fmt.Errorf("Whisper request failed with HTTP %d", resp.StatusCode) + } + + var payload struct { + Text string `json:"text"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decoding Whisper response: %w", err) + } + if payload.Error != nil { + return nil, fmt.Errorf("Whisper error: %s", payload.Error.Message) + } + if payload.Text == "" { + return nil, fmt.Errorf("Whisper returned an empty transcription") + } + + return &TranscribeResult{Text: payload.Text}, nil +} + +// mimeToExt maps an audio MIME type to a file extension Whisper will accept. +// Whisper infers audio format partly from the filename, so a correct extension matters. +func mimeToExt(mimeType string) string { + switch strings.ToLower(mimeType) { + case "audio/wav", "audio/x-wav": + return ".wav" + case "audio/mpeg": + return ".mp3" + case "audio/mp4": + return ".m4a" + case "audio/ogg": + return ".ogg" + case "audio/webm": + return ".webm" + default: + return ".wav" + } +} diff --git a/sdks/community/go/example/server/internal/audio/handler.go b/sdks/community/go/example/server/internal/audio/handler.go new file mode 100644 index 0000000000..95a11fd569 --- /dev/null +++ b/sdks/community/go/example/server/internal/audio/handler.go @@ -0,0 +1,102 @@ +package audio + +import ( + "bufio" + "context" + "encoding/json" + "log/slog" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/gofiber/fiber/v3" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/agent" +) + +// Handler returns a Fiber handler for POST /audio. +func Handler(shutdownCtx context.Context, logger *slog.Logger) fiber.Handler { + sw := sse.NewSSEWriter().WithLogger(logger) + return func(c fiber.Ctx) error { + var in aguitypes.RunAgentInput + if err := json.Unmarshal(c.Body(), &in); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + threadID := in.ThreadID + if threadID == "" { + threadID = aguievents.GenerateThreadID() + } + runID := in.RunID + if runID == "" { + runID = aguievents.GenerateRunID() + } + + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + + return c.SendStreamWriter(func(w *bufio.Writer) { + runCtx, cancel := context.WithCancel(shutdownCtx) + defer cancel() + emit := agent.NewEmitter(runCtx, w, sw, threadID, runID, cancel) + + defer func() { + if r := recover(); r != nil { + logger.Error("audio handler panicked", "thread", threadID, "run", runID, "panic", r) + emit.RunError("the audio handler crashed") + } + }() + + emit.RunStarted() + + audioBase64, mimeType, ok := extractAudioPart(in.Messages) + if !ok { + emit.RunError("audio: no audio part found in the last user message") + return + } + + result, err := Transcribe(runCtx, TranscribeRequest{ + AudioBase64: audioBase64, + MimeType: mimeType, + }) + if err != nil { + logger.Error("audio transcription failed", "error", err) + emit.RunError("audio transcription failed: " + err.Error()) + return + } + + msgID := aguievents.GenerateMessageID() + emit.TextStart(msgID) + emit.TextContent(msgID, result.Text) + emit.TextEnd(msgID) + emit.MessagesSnapshot([]aguitypes.Message{}) + emit.RunFinishedSuccess() + }) + } +} + +// extractAudioPart scans the last user message for an audio InputContent with a +// DataSource. Returns ok=false if no audio part is found or source is not inline +// base64 (URL-source is not supported). +func extractAudioPart(messages []aguitypes.Message) (base64Data, mimeType string, ok bool) { + for i := len(messages) - 1; i >= 0; i-- { + m := messages[i] + if m.Role != aguitypes.RoleUser { + continue + } + parts, hasParts := m.ContentInputContents() + if !hasParts { + continue + } + for _, p := range parts { + if p.Type == aguitypes.InputContentTypeAudio && + p.Source != nil && + p.Source.Type == aguitypes.InputContentSourceTypeData && + p.Source.Value != "" { + return p.Source.Value, p.Source.MimeType, true + } + } + } + return "", "", false +} diff --git a/sdks/community/go/example/server/internal/audio/handler_test.go b/sdks/community/go/example/server/internal/audio/handler_test.go new file mode 100644 index 0000000000..6cb56efe12 --- /dev/null +++ b/sdks/community/go/example/server/internal/audio/handler_test.go @@ -0,0 +1,124 @@ +package audio + +import ( + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" +) + +func TestMimeToExt(t *testing.T) { + tests := []struct { + mime string + want string + }{ + {"audio/wav", ".wav"}, + {"audio/x-wav", ".wav"}, + {"audio/mpeg", ".mp3"}, + {"audio/mp4", ".m4a"}, + {"audio/ogg", ".ogg"}, + {"audio/webm", ".webm"}, + {"AUDIO/WAV", ".wav"}, + {"audio/flac", ".wav"}, // unknown → fallback + {"", ".wav"}, // empty → fallback + } + for _, tc := range tests { + got := mimeToExt(tc.mime) + if got != tc.want { + t.Errorf("mimeToExt(%q)=%q want %q", tc.mime, got, tc.want) + } + } +} + +func makeUserMsg(parts []aguitypes.InputContent) aguitypes.Message { + return aguitypes.Message{Role: aguitypes.RoleUser, Content: parts} +} + +func TestExtractAudioPart(t *testing.T) { + audioPart := func(b64, mime string) aguitypes.InputContent { + return aguitypes.InputContent{ + Type: aguitypes.InputContentTypeAudio, + Source: &aguitypes.InputContentSource{ + Type: aguitypes.InputContentSourceTypeData, + Value: b64, + MimeType: mime, + }, + } + } + textPart := func(t string) aguitypes.InputContent { + return aguitypes.InputContent{Type: aguitypes.InputContentTypeText, Text: t} + } + + tests := []struct { + name string + messages []aguitypes.Message + wantB64 string + wantMime string + wantOK bool + }{ + { + name: "no messages", + wantOK: false, + }, + { + name: "text-only message", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{textPart("hello")}), + }, + wantOK: false, + }, + { + name: "audio part found", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + audioPart("wavdata==", "audio/wav"), + }), + }, + wantB64: "wavdata==", + wantMime: "audio/wav", + wantOK: true, + }, + { + name: "uses last user message", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{audioPart("first", "audio/mp3")}), + makeUserMsg([]aguitypes.InputContent{audioPart("second", "audio/wav")}), + }, + wantB64: "second", + wantMime: "audio/wav", + wantOK: true, + }, + { + name: "URL-source audio is skipped", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + { + Type: aguitypes.InputContentTypeAudio, + Source: &aguitypes.InputContentSource{ + Type: aguitypes.InputContentSourceTypeURL, + Value: "https://example.com/clip.wav", + }, + }, + }), + }, + wantOK: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b64, mime, ok := extractAudioPart(tc.messages) + if ok != tc.wantOK { + t.Fatalf("ok=%v want %v", ok, tc.wantOK) + } + if !ok { + return + } + if b64 != tc.wantB64 { + t.Errorf("base64=%q want %q", b64, tc.wantB64) + } + if mime != tc.wantMime { + t.Errorf("mimeType=%q want %q", mime, tc.wantMime) + } + }) + } +} diff --git a/sdks/community/go/example/server/internal/audio/integration_test.go b/sdks/community/go/example/server/internal/audio/integration_test.go new file mode 100644 index 0000000000..085cda2361 --- /dev/null +++ b/sdks/community/go/example/server/internal/audio/integration_test.go @@ -0,0 +1,49 @@ +//go:build integration + +package audio + +// Run with: go test -tags integration -run TestTranscribeIntegration ./internal/audio/ +// Requires OPENAI_API_KEY in the environment. +// +// Test fixture: testdata/savior.wav +// Source: https://commons.wikimedia.org/wiki/File:LL-Q1860_(eng)-Wodencafe-savior.wav +// Author: Wodencafe (Lingua Libre contributor) +// License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/ + +import ( + "context" + "encoding/base64" + "os" + "strings" + "testing" +) + +func TestTranscribeIntegration(t *testing.T) { + if os.Getenv("OPENAI_API_KEY") == "" { + t.Skip("OPENAI_API_KEY not set") + } + + raw, err := os.ReadFile("testdata/savior.wav") + if err != nil { + t.Fatalf("reading testdata/savior.wav: %v", err) + } + + result, err := Transcribe(context.Background(), TranscribeRequest{ + AudioBase64: base64.StdEncoding.EncodeToString(raw), + MimeType: "audio/wav", + }) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if result.Text == "" { + t.Fatal("expected non-empty transcription") + } + t.Logf("transcription: %q", result.Text) + + // Whisper transcribes either the American ("savior") or British ("saviour") spelling + // depending on the speaker's accent — accept both. + lower := strings.ToLower(result.Text) + if !strings.Contains(lower, "savior") && !strings.Contains(lower, "saviour") { + t.Errorf("expected transcription to contain savior/saviour, got %q", result.Text) + } +} diff --git a/sdks/community/go/example/server/internal/audio/testdata/savior.wav b/sdks/community/go/example/server/internal/audio/testdata/savior.wav new file mode 100644 index 0000000000000000000000000000000000000000..0b185b0d19b2a357030a847f7a1104f306a22d78 GIT binary patch literal 85548 zcmY&=3A|0!`~F&M?{m&QcW^J)H7A59Qpgw)6^f7yg_4p`5|T0|GGuB%rVOc2WGISE zQA&o83?=hC-Rs_a&e_9S|M$6X`|ta+Kj)mi*IvWB-r;%QwbpyJZ~4e0<=RNmscFZD zd%ZHKT!s)r(lu%W-3*aJi45^Vk3l^I)!~2qYm8@n`e?&BBWpmuUnI z%n{IwUV|R_4t-!;l=AG*JKAy?=*D|I^Bd+SX38bpAJ3d;jB@9{xnWkI24!vmji544 za}H|6=ouy82J;D&f&Snh=FL3@JmQf-yIas#zyrvF+hPVx4fg~&FBFs2n|X%O zSdyrRa?DziEbRiBLZU2F=!!*UmI#YbftFzQ;4;SGUeFW%u}ntl@nJ$R%M zu>u`niwJbmDVQhM1O3o@=!r+QF(zvjGzi>B+ZdHO=BBw`KvBYE(*?@%O%8paWx4bw zpT1*M>jr&bfhYsjXpf%J{u;H+q3;}e&ZT?Q&8G6Jbcgyk={ruNL7UM-4wc`a@0;{4 zM$fQo@PjoRJ?H*c%JOF2g9d|cw7O2;=oRB(p6C_5fUbO+19S+SgErw8p9FnyF@k_U z`P826>6WCS(U4)Fh3Eqp%avY`GyfIjErR!~G0?UE>7Z$Lf!>A59$+iD@Rm!V zkv2WEE$C|9uJ<{Nhc~g7;hxz2Qpxk0PcigX*9!&{3Fr&Y<@h^b1-+3x8wW zlmbpfiASLV?j#lP0UW>uo+QyHrQklLfLl>24HwutV}_u6MvTyZ<&YKLF?uml;9bCX zmLwzwX$95~c?R+iZ~z{R^$9u!dj!Va!tVfE@Q&Z$E&g#2tVg%J3L^{NLJJv71C)kc zLX)BC(0ImgXfN)8KtMA_SWp6u2A0pYP!DhT8)%b5A6)1MweW_~ z2uP7vfHo*))BsZ8J!1xAJg7no;2&z;Ea(fA;E9ooDMT&qhtUjAsC}I-;2I+!Mq``} z@CN8L;hg9Kz_qHjXzOqcH9VF%)|VU=%zB zJO`r?q7}w1?hEB8Lk;*2#w%c7v;Z-InyK^wk`^IM&LnIG76Yq+)3_M7fzP;rzlHy+ zi~4AVHb8W|%cQcd|E{rMOE4?Y1U$p!7eCHR6Uc?I@R$pwADAHa*U2Ze97=^15;K4_17 z^p`~3L{0n#l93e6vyWyuvd=aK9s$|_e@=od(2F4MgGNB|kTYuu-t&5hZ33+jsXzx9 zrO_9xl<^eW6JREM2Q&*hhqt)rG9VZ}+zQsnn7|0Z)(Jbo9cp5vpl*QDj2~>hY!{4n zz)+xEcELC90^30Z2HNn9@nDg#7vLbU2<-wp{VO=XPAh{mxV1@0mKMr%Alrnp$Eg1CaU1MfI0g+@WA zf+t=Vv5yDhLg)T>r4-z=js~NlKWHB6a!iVq3u|oXiU5k?pA$4dAGzgsMnz3Re;5+ICs0G{xegTs>HbzXJRDfI@=`zCME#h0CA+U`vVC^l8!mF30Z^WO3j7Ag_lWutu_Jcp$RDc) zUQwWJ2H`x~;2qlV6Z;mlMF~7o3HmNsaFw8YtSR87(2ji%>azDi34BUXs#Cn+KJZGQ zE~pO*c;@UY;5B#+REXvSPQp83)r6U0b;0YZB$5p{hiCAEy=|~^u&4wdNEPyj)*z;4 zdxN#Xz8Lp`F}GqW=o@SR2!S{it7vF1*3QseXgqYCbsr_rY`y~9K^t87MbEG$Mi-94 zFdm~SFqKi2BO72bu#)YLqeQkqMz~w& zSE&>{;_8)5G%T(Xf-r_{Kg0ec*!TbHoH>ODeD~;1r_;?_K~~fFZCv>{KAW0`?#> zLqv+0jpMgy0cOBPu@jK--!EeqY6Ed#qd+WR8Cx-;Iany@!+ISy2=oL7BPM374c6)? z!yHf>`V0ATv;_WfM8P^3=qi5M7juk;=l|=sq1(_6mI1VrV=U}VLi=GmkR?l#y$Ylf zjKDQm|JkAfZNkXBQpeal2lg?T75YOtYXk4Y^32eNbqn(aHid~JkOKGyjseYrl>$~B zc#jfrEJELmc)WW8i9n+G@Es%oAI0%9+OjW!q%j`k0qci6f@qsP3EnVG&_32C_(Vzd zSYqr?88dhefwsYpXu8x2^kIfTTSnJl*9NVjzd$XH$vDD-q=93Ii&(Q6(I9i+VKyNH z&;dxnkp|)pAOn7L>5gODU>(mm1nlH!hkqG~5s?COILf_3zbIjZX7p#AL0^1vJcE&V z6pm{_2jU;TKmo6J@ivHNfN59gx@0cW=W4+>@b5Aq;YD+vu8ReCr~!NgO~6LP`yxHzWf_zd&IY+1WGmUc*^fn0$e2Ur2A0_DJI*ch;b-vKl57E^pcx#jP3r3L4W_urtR|473hf6+}_cbF6=%r9cecM?@TeSROL45VK%y%Da_8go2ea;t{?Og(R4tY@_56%e!l`o$}fFv&fm zpadEKPi>P1@h`j-WQW!)SMD3q4B{$a2I~VyMEuP@1+_pOQw^lTGdu#kEpQC+9i#!- zV8)mM%NiQN&!_^UH3KB40sZp;R96X9Xvjs>LIvd2!s1L0`YwiJ>%=XLc4CpDZR9HJO>VL!he@qE@ z&%TOv3^6cAbUXs2!y`dy{Erdr!?}!O82FHdR;o}J{_+*gx2hfI3_ip(r_9h&evfluH@ExNG z_zUZTtRZb)BeK1+d{N3L<-jD4E?{FpBnl0}2tg(Q_lTGic8L10bBqGXVpP=N2!w9_ zlQ4c!2Hgy#&e{fw_=0xB{(z*w3fMdE!*JA$xuJi?fuKL=Bt9IsGFD+u&{94r<`(Q< z@CJCy9DvQk!hsFEl7+3pd%(tcRRg?$M*`CG`Wd#zt6KDevDsG8H`^kg+%u+d=nWs>EcnRwjGPFJ#q(z` z4i0f2?00!Q%nYc)b3#wJ!#Ddlj1woGfIGk@Uez;F0R!P{nR~$=ZNP2r3%Jbt0F1b} zhtJ1v&>M4?BUjcK%%712bnp{%gLMkCL>>tI3u_(F1lohR3u|kvgn=9=1I8e~2AahD zN3Y-}B+6b7v&KklH{c`s<<%LlpTJS@1l}K6CV_|NdGkJLqJY+5R~gpA-io6zKA(bZ zp%k<;4bVtlEAuW=AOlch3pd70r(gg0pNAoTyo_G(H*Q(ejvF@jX_q90}u>{SimRKn%>9|ACj7 z8?=&3B-Q6=)-Ip}aR59+dO^(2`<%SaKn@0WJh9tZhQ1jod4H6%DuAAd^bz%Aca+x} zSPv8_=o3*rv;!W6cW)U#;g6s_kPo~R5DwG>V|jN5EwEzZi}9TO7SI}=3uq3nkV<%u z)eoK#*91Gqm=|_wpqn^(#6C3q3OEN1f&B+K0Nwy!Sqs1?Vpl=j4h`ma;2^k(i+KqI zgvPPfp$+3P#|M}VB!SfnqAWhc3-BJ}gBRcdbb|F1BVcs=13jP!7Q!}*x42`>fJQ;C zEMHg*;~BRIX2aGD3l1y~ZGauDldwdzWBufB9*1Lpwgh}*4Wl=sWf1Y8cTfO( zhn2xbU^$GWj07C@qOSl6P{O=rOk}KJdqjUIWkkTpurJsh$A&;lcp5}~_;9p{SdPa9 zrgHpv zv7jNW3#=>P5)cq`0u@Xj5QS+4UCeXvGSE)=X4aLUx4;Lp-a%hjYjB4;teuR=j1#C4 zpu;U4LH$7cF&gM&OAJ53vgO*)S=QEozFQFp*T6`aKR6J?QOsMkWl95WVK2z~ zhdBo`WqR?%HCYQ;t9bPp>>Kf3I>#4~FKZ0^A2g6-dhj&RU6itx;G5%+0NvSF1GhPL zg8hJIcqryEBNoSNpblID-QX8TLcv(9VQftt0R=G-Ryw$_dbw0^VW$kcVko&nA3R}Y zbCvu*ukf)C2EAtMft0{ya5Grj0!hJha2osESmUMAhxbl^uZ;h}jF{7mMH~r2KQLEZ zEJM~a#EpTk3FL>?98<#Q1FOIZ#x~ylhE<>!&;!f|Wr%WFE1}cSk|65EZ=j>h@mu>k zfrsST@hpS2Ej$YLl(9}nh6YB#6MGq6|J=&W!+fwW&#mBT@Zo3Pk%FgyZ-M{d7zg%$ zYnKil5`GAtj#pH{%85M}zWELq!n8Q zz#v7I3UCE?cxKM9tb-T>(MG^`AR5p1eY@ZpG)KpT2vzYnek^WZr% z%77n1^oKr~e&!N16JvtgpbDA-+CeeK;`(goJXh#5|HJH|;jHtZkug2cXD(;{04;eB zfiZ;9p1*-L*pcRy1f(6H47>s2W6snZmjwm?(XH@3Z272RIk^KV8 z6*5D#!=quIf#*XhcnM4o?(hzB#(2;gZVipPwY~^q8PtHEWB!6ta2X@;Dw5CU;p>9+ z3%nhlZ}TZFMhiS2XP=@E?jJqji8;Y58m68#5gHT72fvsf?1wP`cR>m6AVYqSH?S$j zJ=DQ_<^sOC1U<5EVHr`^RPPoV!TT}VGsZF^G1fr7;3)q?8%9`;ae|)M-!Y=1PCy5E z&DsE|W5#To_=RlPX4rNx2Ghs1p&gHgXS{_hVe7CkT>KWTm>-xGdqA!aDwrCoO8*ek zF#ez=EEL)dEoTb=wgWABpDQ?F;@!9Of_K0>v}3OU>)<&{NcAz$~y z#Xx&rzhmu=J01ZwQ5TtBOevp3@;aY)l9;<}(HNOWfj&X!c&!FYg8kuQ|HU$gW;0E^ z>cA71K?4H&hOR>=prz1;0EKZ6jeqp$}fu0^x!2JTv|VMZjhJq9r^&W*YQ?*1&A6W%!(*vsHt?0)RKb z>L&0Pd{z%%%=t%r{?6aX48VRZpX9N}1CQa)V?-}wG3e$J_DQgMjL-YJ@G_VK)5`Nk zG=TVk{TQ#v&=xJZHfF%PpS)j;x$uey9OT&{YQQXU;&YQ$NSA2_m<^~#UxqpK$<7|>!r?S{7Vcvq{m;q#rnIYDI>>($3ebztR;W@Aj_Nu%` z0NU9jU|y)t$O;{0KM6g+1#87ScrUgm*df}mhXMZ}3ycL>@SZRFVOz&I@Q}beSUH{< zx0#2q=ipyJDL(@P5c30lflXM=0Uv=*yqdtuf>(&V#syt06@0@p!9Rc>zzFUS-@JYU znqb`tR0g^M!+2GLbtW(jH1oxnhI;S|yf2COc;<66pf-CBpd_E61^b>@ZDJ=1ErY+U z1NIiC4_0z0#|0$j681Y>0t(oZ0MUWb?1xc~EC}Sk@o6A_f#|n(y>JK40m%bzlSV7x zEE>BAmE&}ecap(b_?{r^1L%&n?C02%zzg64uXx=C#AmKCGK1%Vuf-e2b;fh>on?(x z7^6922Gl_t$RSvruyz46Awl36dkn~zBNgO(!Mma*M;%NX`U5Sj3xRfFG>i&~bN&cs zz&jf7)aU(!MmXh-}vy#m!GM)z`|g~h>6fMN-%fO1MYzycxJAN z5?Ew_0>D77&z^weU*G_8J%OJ1$9!cep$11~ZUOQEcYp*ySjI!nfq^&RGT;{bNBm6; zER?+wkR2Guegk!ZZ@i*re}j=2r9lh(j_iV3jC>doD^Jh>tzl&1y=l5?8T^{6FR`L2{qf#dL4nxp}*0-kAV4{ypVTub1u+2N&;8-9uIz!mfcKYfMn@K@s)IYwR_ zyvr%z0qY*-&$@lJpbmT{Vjk=h@hFfms6~8qjegM^botg_@FU}iZ5dnuk6ao9+=3;8 za&Ro4dPe@UO%h5_S(IvmBg|1`x(0dxy#(UIhS{n$^^{C)ff8Zz036dJkA!_k7NHvc zk}Zke@rf^N12!+|R}qIWI_{vOk%HQw3!XYeZQ5x{70-YR(GQ61nW zs7<9YAS*%R!MDJp!IC^0jn_%=Kaf=5Qt_AvmY>Z9d zBCo_zPtsT!)DE0VCYrGaAEo-7{~oM9*rp`aKoo*i9K1pX-7$yI4wSR~fuH!h67Upt zgTH6NxjPU9TrNuG;4sE=hzgsY5JO~9OHKL)jY=)3SCo2z4a0tML9Tq7iZ}u#*k47o zky7v$Iu09v%-Nr((HPJ?@VPXd|3+wbz%b6TNfp=33DWCSal0r>y||>47ijzhjl-v~ zun%|-U>3YHG{z7&uaj-uByOS}cpNLR6a3#9;1`!N_V8H?-arc>jU18(@BwHGUWfwu zL!Z-#j@zjW8i4Eq-xQ&9?Q#^sz)!PA6{R`gF9AvuzaVvZ4Buo>tx{CVdIv6W_EScI zTtFkdBQ&KL#XU-3jgmw?l%Z!xkmZG$*~E!5^o*$8BPt=o+i0{T;?;GcyC}V78A6BQ zKY~>_^dDMWm_9-v&c8tX!8v?Mn$Kl(lJG;&{OXFkX$~jNU&M(ldb*9qI!Lx#LDZz4 zV>E8Q$rSg9I-&wme~~CHCYp$r)GFWnX%3O~Hl(NOG@oPUIPo1TSLnAf;9o6KNnAI3 z%r@$$w5TUqh{nVlA^f$G2t<)oUcG5?^5{ z@J#oLUZNGfU1#Q*}+$){6;0c9bK1 zxkB^((Hx~3rHJphk^Xo@>uU2IWxn4b>Qf0K>`O$$3E~U13l@5oT3jS6DMQwC*6gQo zFq4|3DHqK?lA~`b65sElx4UVUIp$7rA4z8$ad$@n*K3J~iQ`wyUXt@^>ajTKQwq^o zNHit-|25;yneoE|5GQP|Bzhn6jf;S9mI)?ri^Gn zGl6u!BCU}$ehd0sAX!f|znjY91<{%KcED^Po*bgt{%($tEFKWg(VQ-tab}?ToDd|L zbo>tDz+)u0bTPx!G!N5^T8MEphv&sZRJO`=HTRn-rk+?Qwu;xtN_G&HUl(ZAYEzix zzgo;AetcsdH;v3&WDk?X0r9hVpQ!u6Ja1Z>kyLAvSSsd==`_ywrjn^j`gpJCExOPg z4-tpTlAZrT5^zj9>26t~u`hA#yxB9rZw3U@kwM#OAZt_sqI2DhNP8aW(z6* zBHpYuPnhy%0Ll7cF+z+L!^GR9+Y8JirXAVCWTE6m>hUV|1Du*lJq{!6SSU`4CB(sZ zO?NY^z{37CRmDdnh3`lw_L4-CO(#=Fl$B4*^P~Y^ng&Kuu-DsE5$i>+xJoqU8cz?? zzv(IFIZ<8?lMl!~qz#o#TjE|b>Lo&$_N9o6ql7Rirj4GUN1Aa&^DZSfr|(I_ zPLK?bnC2vhlVSm3-y)-Rp8k%w`j1#i5?pOQCFEE`e0<)#LcFLg2g+HZkg1|S)iuO4 z`Lom{;R)txQ;R6BFN(`TvaXn7rjf?aFkQ@KvYto8heX8zQC^fZ^K=CMNbZ)qEb# zt(u6TVzB5bs*ydsMwYXXC`*`DVy~Di-;=+ZO@2TBep5sKD36eyjUldAAT1Ax4kW+U zgvotqcC*EBlGYEz;lae|X@q<8NaubbJ6~Z&o95nhB(1 zbx51R*6*kxOl`Y(wFrBu}%IWr95r6>ihHs{TT81BeI#9M6)4W`kruXkeEx_ z?-4T1G|!Q|CXxlz<|)#xyX0K5`MRbe$>VY2d{jIp8%inK)7)Q_ z+r$aI*neAh7az(6a;bR1e5ebPe;OnfoBjGLX% z*Ln@NWTQM;yO9WRrePolOZ z#U`_p(E4eTQdqoAlKfsjY~B?=%3PTucbQ%KK|M%UHhszNJBlY|RVCGzVv^4COZdz5 zNij^FRBxz%Tf8S1%TltcxS>nx0eY`_NKRL&);ih7Jma_X`R#?8S z@9|&uC+O9rb5F{)s<-+}%+tgCyY;W;s5l`!u}fZ+!^OSkgx;u2n_f`d@#F zX(V?F+qBhxX^ZBTVGWjD^+vC+UMfBHjeMAVvmgX&L0H<0JXL+OLyS}}s%~PH|G0O| z%QENW9_v%9ul!gKB^m#$pO$k}Gc`*l%gSN|`Ode^W<6IQ)FZ@jbx0kPZN)-=yVJ^T zZ7NtB?REBQbyk1t)^$ht<>W8c^LAzH5K(`R-`syv|6p#DpUaPAnyf@Nb5d)vwf(w} z`9U0)G5LjPMm)C&#jcxEqL^Bu{t&HoDZR(!i*1A$Uzv^igkM)L5{0cNtH35@AYW2jWM@%CU-$O<@0(n4 zSZiZ zE9Z%*%n=7o7v0l8?C&#GWJP&NJS2GU2M`3ov9b-oo0~S ztmdk<;x%1bk1-?2t|R&_|FpheR9j zezI97Zpxoj*cvFC>e23Ecc+dhqnfK+xr(e|oHx;{t-}y2Zo>Uc510AsSv6Rk(j7<>-ZUZeIB7sB+0SZbkGEcw_v@wZ7`K;y z+RRYe{>)C5!~D4W1PuBpb|6nPM!X~^%ID2Lu6DlB57_&{MeW6Upj+A7t-lvpvYM!1?iP=!t=3lS zPno9sx#Qhqepxx+DsA^s)6L)BNwTa-g0w?>ZJ_D zTi482V!Y^O2I+?SJ~LQ$vRYYv%>yb}5FTYTE>WsM0|I{7t-zSF2zhz5R zN>wFII-wty3#?jJVPSg>oFQJOXr=bhTq?;dUBkzn>X|0n$- z#hq;wNkdFEOH3U??q?`w%k+2pAIjgY&DO87us%b2SxsLMmc2Igl#2MiGtb@Zr>nO@ zZA0zURZ~)@danDmzsDSrHLZzis!SFS>iPaQe~%ycZ=>OAQj!N%M+{Oa6IxfZtg@V5O-Q6b%bG z+pP2-^7{JC#Q=5D`q6q!6!r3*mEPlKkvwe4P=D3hG}N{I3f^~qIz_98t-%Ml{=teoYgT<*WhfHc`tjzI{WrC(rY` z8SU+Je)cAtp2`kw4c%q`VCp+1W2<7Tymt0C(FM^}_6(ivTuCf;+WF;W5BnSYFZm%w zRM$->l3q7G&e@T8Lzl1{M~a2l$Qxb{XS?&DUyWiZM-&O22|s1OFB*G0<3kc@x`I_F zd?S=(Wl&W0vwp*W(QE2A)}_pBdEAOym8}|jWFC2qQ>Fv0WZuya`v>&_QAE1Jlef#;%qw~} z#e#)RKmC+xASWxWK2$G?&F+(lkXK!NuWH)=+Hm8Nc}UbvYxT!-DC-B(R%`7%Y815~QY5HtLA?@521 zNU~B%CwkC2tfL8=dS(#m?ta}yR8?zLM9q+|oA=$OiL-8J)iS)_-a)?gU5ag1X+bN{ zb9%k*Nzr#-nPcA_Iw<~fn#9|?mBo968tpCDdP3gxM!Ut#G&N1G@<(||VjpSmopOkH zhi2K$Kj}8{JF4HpY2oRjg!^@(xc{i!VBKr26ytO`FWLRlPpA&zcA?oa+5bFoC^1@> zvHuJWv%V3n%uD(OJzu}BGYFsm(zE4O>jKTAFRkj<>uLHOTH9?lw~3EszTBtBdJD`A z)me2lDZU_obVkgv-VC|cJ?1uNV&a(FSo{$>6Pahnb-p{rt?AG4J-@Gf#QxHLQ4Kbi z{dW4KztnqLyRv+EMEE^5-Jj@eOH^>*H_h#;;k!aj)%X5sr?#I*EB2*+s`=RR!_ew>VCRg&mH5_{*A1wippom zM|rBRdDV?NQ~h>UpK$Z=S4w*OV-fdm+1C0>^(1ex!oS1xmJQX9>X4}G&vPI2qo#$t zsXTSBwMf40m-4#kYvhej$te=RtE^6HwP@(S=1g=Cn+f*PPzUvuY3cWL zTlm@HOY(Th<{z;}^;3>0ZxHq5=#qX(?~-4`nj5|v>S)c>dlL=g(-INg+x|K7Q8?N9 z#I*NXJCAxobfh)uOq$mid!zljnBcC9?~OO~%h@HPwW4F}<=#E9_A$r#S$DGRNNVUB z`8Ch~-Ye!`BFujvbe}cW^znK*gWVQpko9dyTb9)RPUi)usA&?~9B!hnxz!TyxI4ue ztG%6PXUj6)2xp=$XU(vF&}Wc zZWF6gbZca|Ra*4+nmen!VuXQhtdnAzPSQ=yE?Vo3&>!k?Mye6^e%01L;oR%B*OrRg zPus2JIDdpw-Q8t6SpBUl@|1W~ekFI9Hr{Hlt@)cQp_A1pJk-k4{hWn~&%G4&hh5!j zB(D2I{XSxuJ<@i~QMZp<*mKaRpCwO&j#woF!AO|rku8?8fPGkN8=Ra>jOTWZDHzW5z z?if8c`bA1%>qRFiF^D{74fUbCTb)ot#0>XOr;6!ly>C&*3?UY+=gh-$we=Lm3(LGc ziHLjL+!LA*zM&TCmNfQPh{4QL)=S~Vp(b)L>GgDHnb({6^}Y2)Xh5j1e96D&Jnf{r zoxP4`xm`GVZ{!!b&EM@Fb|3N5y%ydCu{3-&`aq(c z^lfiT{Ha8?*Iez3v`m#LyJVC6Uv6y7eM}FGbW1K8*)5L|E`F;g>+}AR0Pgd%Vo{&-i0#6<&mJ^PkeLP^SFJ`HYVy8PSkV1m(?P#hn^RU{g5t0`@9`QT)ktJF*_2Gcyn*R^=Q($Na0XB z5sR0~trJ@%h9#FxyBNCSZpa@U({xahYkiQ^FM8M-=C+KDjt_Om>2B)o(D#w*Nhj2B zXMg@4r?z;A4qJcsYl!Eqes*X3Z)>C)?!BMu=KSjRjLb=UGUa~NlMd~QJ3aMc>B%o- zJG)xAfwj+X7k@WzRN|7gJEd9jFty5i#vS8n*LRNVOX?YGqP59>%ZiFG{5jqd|CE2& zIhE*cK8SWtFP1`U=iF;IGn{LpeRzGOntIwD>h?31I&585VVy{HO)R2FASwBnUD0nB zyORHt_qM$u>O}9fin*s^6XK`6i2BZ6Zmp3|i}xwo$`u{t0`aGA;Fe9y@Mc@>lFo;p zP*?ogZg1E3LS}`vA@W+Nw|UshaR++)y({_+*~i*phpe;aggZ2G!O76gq_&ch8Ylg( z*87W{_3_+9FWp}mds?_(XsNXQkK@^~vHm*y>7=(K(z=kikZZduke?holKdANmMp(soM5G_BUdS_qe`Y?Na$>hBv@{ zOuweyv^P;yYWXdkkl)bsAun3PUTD2ZtI}>JU5+ImFxlB5H`*iBaw|JDT9tMB=l66* ziJt0R`MI5&R5Nl(KNs(qKPuMTo1?}=V$na7w^%>K(r(;;V|(KLWSJEyv_&j%#>9%{ zc8WizobcF?MI+py=IV1!k3^Bg2MH_rgm6gss*OISJqwaC{s5)IH_~! z459Gp_zH*4D8gRSX4%SlDRJ6t497xm=nna9a+BPqk*Cr#QahPFxvla#`AzInb}fn} zHi%~7J0dU0u=7Rixbvy*t`3L0C8dNO&^==d^56Bw%jOivFQo`;fJu(Gil^$=)$Qc} zKT;WXQ`JEfGnM`6?$_=_U0F4@rl{IhHHw)Z)s6k${(5(&Q^)UaolGho9cHie=H)+~ z-z)KjemYbkc~J5y`?9+{w%=Jw`w+ICCkETQ!ev8!L?JJnSQKC9JtSXNU)p`5R`N3I zTw-SSU)R^fXN2oy%q>zaxwF&rTC?kW65oa^7FwP(LXU8+yLZw~>jg2#JneVzzR{hn z+V(~Dfx1h)7f*?`HR+K%lS;~vQ_Ouu^i{*GrJ?qrruvb**15MiZLAF`<WQv+c=t)EcW6%kugUr-E~j{zv^JlXTqtNF$XE zy&9Tnj^@qCekeX&4NZC=xpuT$Xtm!xem$|&JLgUJi>Ph(vCyB9@sZB*bpGzEw_npv zUh3<`Zf5){AI)8JV{-lhJ>K?157?_{Z|PnYG6xbJ@;{F4)@Q;~k_#s<4ZUH6vo5cC z?ySUb)`@8Q=q$UYY2!9>HaOS42`U!;DD;tBX6E_D-926>H7c~g>h7M+E${9RS4nLa zuI&7KvtfKc`SXO`Rh>35|2e&1d@Hr-NvJ$e*OO`XLc5*n@9l}rce|;Mb_46IwM(XW zUE&`le$W>}C8NLCmHqKFBRX5Om)XT>r~hGVk(lCs5KnXWh-d9J)>*lTBKL31o9@AQ z7k8j)AD${d_DhMkL)YwF@3+_+ZYR|vJjkv}`(8E71!qg*Pdy@hAv(~m?QFj^|iN)6LiK~Q9tOt>dy0@5d*AKp{z(>d!b43F2r{v z`uMHo+|V=OhgF`pIle4$O?)2S5LzVb>Eiy=-dVqu{GHbiT>bvujp7#4Vq{l~>F>kGTB>ZtSF!L(m7+w>BadXG?Vro5sL zd!@ad{xG@U`c;iLqkO3)?W;;X$^Vy-q`%$WdPYn#HDyIJ+}r4m^0%p~;iS;ZVxjw^ zGgtSdee7@O46~Z`MyQch$^R&JGxnA4966NyLuj|&=T`83)33=xqQ9T+*Oi^^+Vcj@TJ4AHK~LNpy^TmuRbF)*InPp*-=GThpCn zmdmB`m~2J+k}dVc#C~_LY!KcQo~y?DqZ2>H(>*1>4*e6Bs*_$%zP6b7-J9fpN9O`PeeOLlz2ZjCBKz- zKgGGUqiIff^X1rZN~oLvV%%~j>&=u_(m8yYb}QPs+hg~{-_k#ZGm<7-%k`W7R+A+u zV)2CEP-Louc9Zbea-H6wU-No+hbVg8V~)zAmSta13Gen;*~DCNdw5=Wx#f$Ix}uxu z&evb7!gf9RtyxH?!hh%neh-~2<8r>)>37pRM147#PA%v8Nwix&**s`fjEs!@s!DkG z$2%lS_=V)s(14_TYo=E%(b*kDJ1;Bs+h#qT*Ssc{(C$)0SzFg~Kk;&9ZM(SrkhNL= z=B{@~dX5+uzD#S}^Zt16Guksa;O#R#Y0vjNIyoO9pO>$T68=W_nm%c5jvNcWC40NG z@-ySD#L@7Da7$~tspNg_{Npb32Z>JhyLOJ==y#N(?Q)^})PDC`EbRQ~D>*s*ZAyCb zhcXf$adTJRDmSK1B^6J(5;1a*`x5OUt#&%;xiW6Ih|CK=Wc}$+NcCcPj=Et4i z)vU;8k$P4&ziMK$v(Ic+gRSn?X1P;;<1TbR_UfBAtuEoIq2;o=-`V-pU1O%xzT!{n zW!gum@2~S#_?5(K@&N5JYJa@xr+&7o$!fZc|Ga)xgw#nH6R*mKW|i01`q$GL^qjgZI#35oM*IYptg!_{w&={4z`Bb@2FB{pf|#srT;e1 ziw9LBDErCy?Ben3*t|Ty=Kt*_;1}rJf{+p&a`MnR-I1i zSLrQgA)O`nS8rJxR7rEuy)*Hc)8CA;Du>G1chh`N`19TJUJ+5l8WcKY?HB#@X0NAn zDZbh}q}*_k$ZYE?zeeK2#A|w_wMUf`y-kK3q|VaWbXW7DH{F@$t(7X2Vh@l*X~&?N zZf=IiZdN~v4TtNu{oS;y`lwo{z7pxWk2k@uEUSm_O!~+w?~REqPjnDJhw6no$@k0y zbS695|HbR7$BE5yqxG6yM-DP2=wz{@>7y$K+C<-O-!GKZ|$ zq1UY!L^ZuoFQOgOu-Dhg^d^Zup?kuc#F>PMf9`)}%w`ZK?Y zSwp!N71ceoBR$gV;=gNF(|Ob{a;3VY4yZ@u9r`WrF4|My;=SnalI=qK?LXz`dZXLZ z8SgcaGTc0}lQu!lIGf!5<{9!R6~!<*mzykas9bTuAL;e?En zd)V(z>)pN96>F_}m@u+2olea+YiZ3?S(P{K{0`a`C)GOngz4cG^=gPN_7|ZdRxv%% z{l>ej_sEF-s_JD9_&5AX{vdsmJVOsvMbCA2`Anj?7O0Y>?2FinPVM)gt;io%V%{r z@*z7+WjYPF)u*PE_oCN?cFsOht*q0sjw$9h_ZE|!R*DDc#Oy6P?>k|((iucKqA;$j zQ{>P>o)kY&v`U$Y`fbTL5SmfGLzvFm(f=!v?PxnHc3 zYv>f{L%)$;N~bR6p}U4m~ZCL8Q|-5uK%0fU~bU4 zoT1ah5#Aj>oywXw#E-Ps+lor3Q3h8?enY&}v|s!JWs{8*%PIe9v^hd&b9uCfO}@~7 zOxHIP#1-1fzFVcr?L<=>%6(`d&yx1AnHW-y)md`Kr6KJlnB?Y4KI{u`N=l(RCCPQ=&I z`Q!@mBV~s*pfU300d<9P7iJSGWSSyoKGBvzxkzD}$py;Zs7$+~dnxN9Oqn><D;%!OqVI* zC;gTGrhi_4B4!Z+U#1i4#rhHbj~-@5(B8|Zl-Y94R5zc~nb(uFuUC#Tw62@l^gkp0 zZhoTdw&`ZFzSIBO+vBzNGxaikmQIy#P%h9Xgo9dCp{%EnY)bySoY=1`>7jIDU5K*8 z-lKEdIdsb3&?Iy+G8n}PIYxO@Z#tdwc9+{ItLQa4b#FrX8Y^TLt%$D6x|A1Hf=W74 zme?P}<)M^G^ogh>-=a*R5wZy79hH^7T%%r9Bjs0=i}EXF1e~TEyl2fB+Dn_Gi&N&- z^OS>i-YlTA?t{7_W#dj2?L>FeLw`V-8Q=PyX}9S+lEUBmcmKL~)SKn+q!nIOG0c3S zU!shZ8TtU7y>9Y-JyevVll1o~OW||VlIUq9mQbcwY03yGMOmsf%ul)+$#5N=q}NqL zDVIXaHu5vdtY}V=+y`_XJxTsV8GcPj@BS3E@Psmi-(Lu)Ca`Cu)MgB^d z;n4nG5t7whIhHcRHc?K~C~-qvq>K*Re4xW{LB6}$}$+K)3xK*)34GA$6lR9-mIf}hH`M4kj1@29`;Ya4xMyAO?gVT zIj5KC>bfaKe4i7RJVz%xi%nz7B5Xq0FT*I`tR3Z*m8V>+Gc?luw4M{8G}77m+M}$Y z9+Wd#ilqJ}>D3O(2b)1SaZGR4i}WX?1=Hy??m^0fyH2`1-3+16dGjLCFqLwUrqRh1 z@j_IPJLGY)cu92pC}tGoo1P;tRgbi%6rE;IrCg8?^bS3TaH}k3P*tTFo*k!wDPTqgl14@wSrwjx%|bW3`zw|9VpP+XzC2 zVP+C#arH3|n%m3~y;5J(BgoEro1Y1fFHmMdf-;ewBNS*t`Jwo$p-PlV){JP^raI-1 z^&u2+DKD)BAxC>cp=ZSrktSDDcGy3JEN_TA$Wjp;w z`fwLfm_?b1e~_f_rrfq<${>D=o-c`FvJ!c%PsJ+A^SnT(fbUZd@y9g3sg$w1i!zZO zpsb}$;ymf*aj}tdaw}4akm6z&mE=gxsjA-b(guQ$LI>WBIWi}q#U`U32lPL$YF`bxBr3{rF>TOkxVu~;15Aw3K)D5{u8aYlm z>K^M6tCuy>>SGqcTVfjX;-P^L;%ibqycHrl;3+Fit_CGs=*EShI(&#M<|m0+|Ti=(eA)S|A=1{xnG2$pA+WYr_cGX`_=t=q^;lj`Gk|H zx;W*$q*8Vk#Tk^pw2^QqL^3KSHV__kk#z_O8qt_l)i||YeWAWrm8>b&pH>}vgx%k+ zWn1<&>nGZ69%#K|zLID9QUA$(i-?(pm3x5K%i;-MDyWvi_HkbRr|i#65y z*cxs1wO*wRDrLW8pR#w^AK2~f6nmAWtg4hrk|FLfPwE+dDcV74?Co|Jxf|W1?kV>( zw}E@cnd)?NUUn8ZBb?IC)Wm1;k7MidThX6MoXk6$_i0{*yq&qba&vO6ywkbka%<*R z$*q&yFtv%0j;J&vN9h4NkbFnPB%vH_iJuTX!eTv{z?tGE3% z&8BFmVyIo{l~9GyKKp>3OXr6VQl3pc`xW~+yNLa!byN+Z{FzrN&gntl(}=Gxsjh0e zI;Uc4o_awQRg1{OY$wf~NEy8o$d10CY`i;+FndXk4Jk+YNfV)`^5!wZ7f0vlBBl$K zUDmG<^1PzA>eKoN)x1iXUh^nBekf&74-xmt8}b$PF=75_Lcfo!H|%qiht|%%*KTPq zvX9g0?H~3dp>3h#p`Sv>LJh;~!cRrK$ke3L(N58h(f-lx(e=@h(caM^(NCkxqr;+| zqb;LFqbbq*qurweqV1x+qTfYB$tlUdM>j=(iC&A^$z_w{wTUS z>POvZvE=(xUP?WedOfvNs+F=o`ID5+sSQ(arqoHjmU=Sn*Fqnsf1NQbb9eFg%QPzA zs^aX56)S8i)3xaFXd!bc_t{Iae>)srcJPHm7ydba{)gYQRas8od z-L7`J8opNj`otUmWY@U)Y3`%>SMqP>{dV(}>=D<;U47v4>cYkc;$voD=(cP`^X z_e-6xTG?rNp+pI<7-jG}6njyo3uV&`H_i2*-g>v5JIpQRmGCE1me&z$aAafhq_iFB zOAEi8nUs~2m0D~~){As4&Dx#yW!A8)RaxbVJyon{v8Kh&X1$%IvIZ30pV=|<*CG!U zxm-A>aJeF%7g?RTv*@I()mekG9?iN~bav*P!Y`$_OB<5XKDs6Ruw7C;EOP13^STj| zz2y(lSLqz}L9(gcvM1$RWO~mgl4E;vFXw!az4k`y8*6S1%HEYNb86;{&k;9!-+bcc zuQ?exQ?h4g@5^?x6WJTG&t?Cd6Td0*cI0)*e>t`-{(#fYo#DRezTtjOdmA0x>TbT% z!OisAd3(KPdb4?pVx?Ymg4{zrs1C}tlvkQTXAv9K%hn=7xA&|I);ha!=r=pd&b2z( z8|;bpQaUMFPf_Gbii4GI?Kh`9sJZ$z%2$4!?DISF&kfA)`gY19ZsBcmA8>zh?sEnw zk`v#?Q{yvZonlsOUjE?xmHCPM^0A7sBC!W!>tiYLnepX`8BUtp#;xpLa=xTLCajg1 z9j_5z6-(q#$X}G78T&GJcRV98&Y^5I$}+oY(uhy%tUE(x!o3Nfp9poe>sxWkKO8HQ z)EUaKTBojC&x9h8wMk8ryQJ(U3%flnxzM?^GHEqaTc>=PJRteGsdrLR(&|XxNCUF?o=GZdM}JNF zA!%09LrK+=S|@#*bZ7Jh!i9>_QPIDm3zHkCsFcbneNrZ;97&mxQYd9Xa&q#P=rhrJ z(I*IVx+Hta4O5S%#S5)S@0-!7@PkFNGDl_pmibj?TIPu&IfXZ8yp(<}O{Z>2c_L+E zN+`8l+KNKe3m?mTqu6~VSC+oJ?1tOhlz;w?+={>rR@?!Oq z`;XT6tJdLz|7<#U_{Nj&+|YMv^D@g8KUD0V%oV9U>{ZUn8y(L7dVJuaxAwfSv*h*{ zwvOBK-nG3sALO>oosj+Qm0!=rPV721>aPb5j@tk3{yz?s zJoMe+0mlX(Z+CLwX?>>lxwhvYzi{f}omWz>cg*RW*D~JLEvLU0dFmPa;gAVE5Ka!a z3H@dDlh4uLLk#ts`QP}5bX9rPUJ?DhP?4eyOZLBQPC5IIYj@PRqeS_f+cHY+$?93S zeA->nLg5RP+qZ~zpz~Bls7cb%lnLqgWS-0#SG+=rPl`{^3Kcn>Ry+Ej-Q2w6yp~_; z=Aj$kTyJ}Q%JmjEM$vyrG9-6>-mv`7^SkEjyq z9^+JZgEP(9>I`)!dO!LPna61N|DEtt(Uz%irtd9sGOJCA{v{WdEK~Ae@$_POnY!@M zjNa+f3pFe>v{1+NJ2UoY>@VyTIh*-a(a*9*71PDe7kj+e%&f9mEsFkLhZMF{g~qOq27F_YkXf`k()2y zc<9>vD|0VLF8_Gxq039I^t)E+#*f*ZZmPU0`DgP7#{QzKZ)|n`?7W(}M{>?(ch9b! z-7#C_OuRWQZ+NU`BHyX*_c49sVwGi8uu`py>Zt0jUXWShL0a`lQpA_u7Sbx=s_90nwidMJ$tNGbnKGC6yQ7@*@m{gV^WVz*H+N?4 z%G`^&qw|L5zZIJr|0L1x|8aB{U`||Z6wYWo*~p2yzGUf*88e$6(LX>A8vV_Pd*dD~~}LhBUkCTj_sYRhd^t)fl0{b#S~ z*y5<+oagNBJnXFFTH(y$$Y&o)weZ=|)fsiPb{2O{arJNyh)th9K11_NW3uGR{w?RM zTtDR4lsR);C+louyywu@>Tj<g|>+$B-F@rL0%v>|m&A77m%0ilV z*q3UrJP*s=YIiw!Zt~d$=Qdnib1ify{ge5xi+`@3l+HUn7-n*u>Yb51{7a=bKRoGr zH|LE_S591RdewTf-o4>ZCcSF?;lo#N;;IyL+RU^bY3EY=r~aB+Dm7>7+|=P|$2_fl zAN)TASHa60tWDPUqIa1C_IFHJC4P`*Dl5$eY(F_`$COA{DC73bE3+QY_FuMMS%1vz z%D6b)*q9y8j`ro&QkK8W%gh1GD*Fl7`Pkd(b7l5r{h0k}j^R0c*^X!FpHYmf>6~FX zBE2@QgkJ~B`Lp>AfBB#}yh4O1d;!_~)q%00`y#kJU3%CXVrvOYG~ zG3Pg5GUu{pwJj(HqcH$&G=I}#rxv`-kDa5!N}qAhtt zO6j!i9+U5$Z>oQ0;E!O|(1cLeP%zjhcscOJf7Ca_JKi%R?N#dEsW(&Gc>4J62Zn~9 zYg?l=VdGs>yIE2#KU=P#}kv=H*&3mo;ZEtN` zZTW0ltjjHx%-5A?Ok9#ciB0-It(W!~Udj^?+jc@QLu!D)Z&!NL_8KHbOfXxf?DOeiPajY8bj4oD{qgEFS6}Iv1)H zUK8FB-U^p492u>h*1Dmx`3J@1FU-&nQYxu#s`@*Y zyVeo5MC$dqcEjG&G1-yMvD?1he#Y*0v~curtaH3~6ma%+ZXhBIcI|RKba`DrxNE!f zxfSxOHbYrLzwtCY**9O(SjnZe!J{R{GI&Q zI$V@5Ou1O->VjL(9(;H{^!>H?GM;Ggvc5K2$jBT1;JFh&`(5v+CGYOKmh;k_3x_XO zzk2FcxrYN^41XX0^-G(bgj}tB>7zxFbg2{JM z4|opxQUiIyBed1VEtDF^WWQ3{ywx(nR?M;4`Pwzvea}6gtGdeB#<9!x)H2R&G0!mn zZ8>OdZ6DwiV_wE}%}_Mc;>>E+yxG=e3uFyvo|18ExJll{6h=8S-MAec zAe5EB*f_2Bf}Eo91oc zPY#5cq&h?Mf-eG3{9}FZJq6OXrYucfon%W=liDU-OzNH7BV}>wCTj2IzJvar!MN~< z$V_dyK9BX*AMNU96pz=zd?tbEv;r$OOtXZc;$BHsma3U7`>ZSNC!NFGFJm6ZZjNi0 zZdJM->2Alpjy)Dr#eLR!(ox7^*hBW_jzLbhdsj?yY>{+T(wEEdEyLjqnKFz^zdW5I zu3}7o*GES~`*dqHOI@;Wq8e2DnqQi$TMAek%n;H;C)jhn;SwwhKMNHPwF#aJ zEDLlD6bj@H%nO_fc7T`cHs(be3lZ`_FL|V#Q|=?R6yt;|pu#Wp4(Qa8vZS!MJ~c3vKei`IB$Y9JTd+?{x-@PNA*N4 zqc$}X2AR1WE*aS$S*VH3T2=|4#A0$!c&$y$9nFu_a_Tb0BUh6rN;amuXT_knRO$pL z;hhpxds#kO8`!%#DmksLVXnQdHLg6a`OaA90LNK-$X3X9!n(?O()xo)Zl746UtLXx9x#LF0u8bMs&hLskIyu@nK01auUpmXXCb|@N(U?}T_hQe* zwunuLc^vaLW?f8~m}2h5&g_net%5y=V~!)<{?1m_-ik_Lv-2nCQ-{?t#@2*RCu~uy zF3T+Q8uJ-T6s>DlFM*)rU--!|3tKKy**W%9!*w+~z!aYek^>&BA1 z8J}!_o%E?=(plfb$nT~H#8|7bATTvK`Z4%?`@QhBh8MkOSDkHtVZ_x*cV;}P`zGyk z>EsRGZGoG?Zo%FD1s;EL@r15lEFb&73%@D#X6&0)Z^yiE|Ech|(Mhw?dighmMrx*L z5XICQ>A5sXDk>ERx63MDl?y9BC}!moOvO9$Q+cV}2s9|Ode*$m`j5Spv!Gjzb*A&D zACPf=CVS>GnQfVyWm=P=Y`S_eG0v6NM(Ss&r1&E`n)kvp>7rW0w%GY^%;0ptW~h}> z&#*durntJU3)UZ$Gp5d3{h;psl$JYfZko-T+5c~FNTj6E9(xpx@X9cgZOBzH5z`V{F9v$@i|MaEv7WVv| zHaV?~r#XgH2LdZY>my_I644E&+TvPit=wMOqg+=cwY$1X-3})CNO_{fsgKk{=J}Q> z*1@)-_DA-Wj%Los&Q31fb<{mArdg~MH#lxv+{!p-T({U$F*n?MTx*MH0e zPRgS=jRXbRHZ zH;KFnT@U6B4hbCd|4BceJ}^A+B=Bp{5n2;E75Y7NHP|J%Be0cE74j@f`!jV*N+7vg z^7v#^%BmDw>bBGsX^%Yjz1@9DzKNihPyMz1>wIqCIqw8-il>8TxM!2+m1mmwjc7)8$@re)&3@s7=yLX*oK#Bhp5=KXEAwVvbpa#$2;=d zE87ff6>x>@j^@t!u6TFN*!>_+!E|5J^-E{O&5QdS`#5H#JG)CI0!6?idfEHfefD#X zxz5?Hs_uSno4bQ6=xpX3(-y`BOI}=!Z8`+&ZfJO{`d6TVyil~DVdCeeowOL>*)6j-n4qV z>0|H2(Sd8ibz9ZgxD3ZKuE>xs)@NC6O!A!nTI+4YXA>X3y&HF@`JLE%j~@Q@yvw_W zU+*TL_H7IgG4?b4_^O8kACgCZdh?>hg90~yypnKn<)z=Qw!1a{;o_G!KbB7Pr@i-= z3k?k&3smS-W8zMlW3% zMqhc;2P1~b{}fhLA`=HYv(~NAO{S1gPwFmzQ=XZxSx?&gI9Iq1x{Vk;wpLvIxF&Jg zaoz%|1CEXEyoA@0X?71DK%+a6QG zRm`5l(upCPd|JeJ7r)1it)QPF>QZJ@1OFQbx;j0>` z7s?npq8-s?L(>Z|i9D$vg1>YRcgQcu_i;^rilvB~=WriRAOzCItYWW8o?S}cRIsi`YOL3Nv$+Xz$tH01R ztu*gwg?>{n%M>+We4hb)Hgr0hY1-pRa`S>+RuBHw0bns5~WeO}0-V6O3&Kap6X%NYU zer0R80X+MGJkErw!uZV-86CM5>8%yyIvp|IN4GHr-zJo2jvvR1jPd{%ZUHQ@^NWd3u7c(RA-QblQ*SV{binMhO|BHfjm zqJK=48!01|_R4!EMKk&SS+wF=P^dOza{7(w=TFQxx)XJGNIy~;^^*;$oivJPb0Si>C42+JQlrdqBuz3^IJ*lyZ)JE}M*ICnYEIs?wuuHLRQ zu4e8@?g{P^?klbdt{B&JX9?$J$6m)FI-sFWpR=FqiOU6Q^3Zk6)zG!ndDcvehk)52tx{C+7Il`~9Rh>f6g7wVDrRb6=JGxGH`}>X|?teVi~&nl08gE(U%| z(LQf|ed_U-JD%&euCBXw=VrnC-lr$u3g7moW(*ES4d2peq}>jV^m>zL$45S`c~|oF zh?kRJ`d@W>TmQqH&vHVA)b##Z;q`i1ct(q)^m1FNfiT`U89BftZ%Xh&uwp1BlqYgY z(@;WYkTXz^_p$k*t)!EePW%{$kIn9;X{l!(?wZoatxzv%* zvDlu;e%of)j@zm1U6z=6vG?L`rYn~IP`c)EZDMXYW&03INj0zhyXa>k{JUwmP+Gb! zw^V1Er&?-QcUZevXIjdcyD6=ufN7Lr)fR`Q1={)_`qF&=_`3vZ2Frvhgj+?nX=n7j z#teO!);F>(ygT$$u)Tk^x1pzE+Jw|zspnJsrH%K5y*Kirck+XBz!HhMVqF>k%&q{H?b;{(YtaRWwFv%xq+{W?I=-PLr}r z<-|rpE!43+P`GXkUkQ~D%?e%#G!Dptf`N^JT*0xyqru#v0ik`NlcCX}JHZ=)L;in! zJG}Y4eh{EPyt|piHuRnHmGO`AFYvGQ&+_;45A;v-zw}QF3=38c%?wRs?%FRjkNRhI zXcj-GO85mBg_|jMUsxa$j1YR56TGwI{9YC=Q)i~GCNTovtrfepw6u-6Yg6W~334f= zi_%7EpcGIZ$qVJ$?C6ukjzaQ(axLY!QcNA7E>V-!!F2mGEW0d|Eu}1{&AH8m)nZD1 zrXb_RYw!_jQi1FcPfB0q3Th{F9!qyiJxd15K63=!Ng}#}KgB-6S`g;dFamysyA^@G zn207;RRwzMQ|49XFRBE;*(2Q(?+Dq2BRKtRVs5-fxGh$ZcX1~+n;TosTV!hqYhG)- zWw=GOv@(BHuc%vi%u{=*XVjJE^_K0{R<@hA9QMKXpuMi6wBsDJk(M?W6V)3w3rJUW z9*gWn!8j^{b(U}*z-)1%J&S#W?H}tN%P*G7madlVmd}<()@18STV*C9gX~^g5nCtg zpO)O_14<{kofHrSsk(HGt0%F}tklO7)L+!e$~M^{FQy7K$;)M@GC;YZj8PSf&sxUb z)_%}-!LnGnCk)hI1c!TTq;yQMew+Kvo7mX%Zz#9vrLx|(#yQ;8-kHVrNG@$$^jlIA zzODWgdN=S**4G(dAAd9JegDs!nBv5m zl0)7o*GGN-PVp%j)&4MdADSy#hFGGOXV%xYRC|BN2uBge0cKJIYIzcm47SuIeEU$kwJ*vQ;)!*Jv9r0~k{^YCzf(gOV?denDt z9(|r43$gWrusn%->OyB(u!BQ%Z@t-hbeconz2;h z4i-@h?m{-0ey?~JSxg`BEvTMa z>6P^uX5{;{jUb(LC0Ylxl5>puI1tXEKe7v|@SR1p5uVaGcpw?z0w%%(ZNP*iAP;Bd zG*mmNop>}+OEJ4q)KAJKWvo&{$)S`}8Y_P)TNJyxL2Y3!4UTZw+QznlnMecMX=?^+ zV@pr-SoOX#RH>?DRSJQ(&rou!2h>sK`b<|VSX(mpENM-(3?@$9QlBajlyakC$Q*}asqCfpXF8kxvMtjgpZQf#;qKDQ6Ozd<%V9^QGa}gG?h!B(+REf zZ}h^o

^OBlW$ZM!ps)730r+s`~!vn~ZPPylM6>_G9rcv*S~e(x;v9&i2<1tPVUS zqdoEe=5u;>r|f1vIRBgQRs6E$^ViR7zc{|NPpFr?KCOcPx6pWWZNKaF^?0p|HX@QM zd@pz}aNj?`f8IYVuqXH>bR;}IvJdR5CcJV9uF!3<2CVd5s>9sS{2%JS9hOg)l@`JB zkGY1qfq8?u9_sO+rM$JXb)@y6wV-Xit+d_lFdWHFuj`__LCpD>PccJdcDhfx20MNB zUA7h0la`8>%;c~3mVYfpt%t1*Y^r^$Ju{fs500buYW9b=8n$iLH+Tf2cDsUW@ z!n9!K(1}pDaD_;GWC=WkQ@D?eg#+IdCf!{)lbQ5WOe1<}+xcE5qhz$TX@T%k^hlq$ zY7VuYT0ym{=aeZ*LFEGxdjc$~(sE7t4_HgG@`v(X8Lm26smIKh&DYEa%#&EH@6<`G zwXupJ|0S1`3(JE1o_Ks)+9LJFyT>EW6!VC;@m*>pR20exjbVnig**A(duo03DvY=} zdKUe@wi|?WFwwH8Rv83kkG57@&%f=~7Ha*p23k(du04oMh&Usc!n@$4wF=i{)_nzC zUOZj_2f~}f=E$hXBhZ&!^p3l=a#~8HhIXDK8uIED^fC0yMUBQrF&^!WI`CWb;cvEs z_*VjEZwES+`mhg6!hS3xJPYcVJ*Iko-l0@=7EYo zkk2aV%;hZ>SX5Ok$CxLNR3|766zX1}?JWsAjSJ!eTZ zi{`MhRgRT*2@CK~Eet#RIId``q>IvC`Mxp>?DiQPjw|XlWgRi-khm7+a5G^C6KRqC zeia63cA){NO&7e98j5jZeVEWy>EV2E93R8#oD93NG2GqU(N}!NX-2X>5MAys^r~;Q zbIh?6{hMxrb-xNG%t747o*0FVE&3DfMWlS>72ZXIBj+QvaZ0+SAH{EO6TXS7a3pOqUs4N!GPRY5O2@<- z!b|*z;$hx@gZa7uZ^cpYXY!!sY#?41N(#N;VSmD-pe!ojX_3d_>fyVgJ)wU>l|$)5 zDZ!V)Al3ho;OF2Qe(xD{Q#p4H-1WEh=k)t9c-MTT{Nt&TZGo`=ga4s_l3xN7WZvlA z@7?Ns<^A24-Tw+z?%-h0P)vAi_-(jKq$MmYA@Yc?z2R5k!{LMB_jp58j4Y4j(~fK7 z^m_1n!_gTmUb9 zp&TNYWl^ru7eu5*Qa1QZeZ=x&Iq?^9EIiewV1hTL6v<8Q--qwq2XUwwuZOw% zGP;1QJ|54++Hi?wu)wS+NlL@UU5e*rbN=NHym*luUyW>>!hX4^z141@-i&HF^q=%b zdK0wzi}n2=KPvv7os9CR@e3F~vP$w9WsE;?4!vXKhP8YFmUamofTp8=Sj*gK9=@pC zs9D~dijukK3JZm2LQgSG?26CIC;H~G@>Gz{fpTk(6IdkG#Cc~mpR118m(SFL{^7Hf zPaY&+mXFBoi9IzIX=bvkqcK(GBbOlyG0T!Q(6)%0Br2CB@J1 zW~T|YP#!GD;j<;`kMgjwSAdNE22;5lJlj63;`eAUzN;$mvV!;AmMfJ$YK0Y601e9u z<1#arE_}*0#(3kZK7_neLhqtyH-0hJ!QNfKRcH-=J|Dh*vC)IfVf;jv$NEcHzl~Az z9n&)!9rST}KCW&}! zlp?N#_c2ClD}5HXixbJ(UBtV>Z1}IogjPhAvw}-{OP6((F5->6g{!n&z5;SwTYk+v zDYujZCi7}?%_%fuUqu&gQ%kWv8q7bF z_B660vL-S+(kAj_WE%hPaU@P_t1ZwxT1kBZZgj`>v0%YTJa#kttU{D%qc_mIQD2lN z9vtK@7e!GrhwCs1Y~$Bxr|2*~M*w9;6I^baqUSD!l4K`4ZWi89HQAxPxc?=%A6wDU zjNppZL0k0X|39Mz{-z6Y?lqfsn2N*bKWj3BYc^oT&J>=a(TFALU*{f|6#f;yp#lF0 zK0F=o+J8{&T^G#c!P4xV1+cwEsfCorgy^a`jcMyyu?8z>3BPi%mU^&`s*1Znf7(kq zrK!>>=`cIy0MGo4w^M7}&Q^+h#ce_fV)ap0Q5SI4S3-K$VFvVFsbt#E#GWFkYy&6* zN|?@|Qc0tVc!OfaZCYd6h1;zM-Nqug<@<5ttp$=j7ClEh6jlEaiEkSwqC*Ngl+Cc_ z#-gnF11;KFV$lHDsk$)>UfmO1D>tG1C;|hxH*R>{;oMe4iI7{^Lxp)bdJ1RBuF?K5 z&WodhNQdTQ0sahN^TK)Z<0tej|H2SI>~6B5R<0$ibQo=}$8Sdx?VU`}uE$Q5NpulfTBNtBSM&kuBC_F%CR0h1h z4x07qM7$(4V0GYMOroC(5VeYeLf)s|jE8Mh7nJ`uQ2Q*>AUgePVm>g+5#m1PgcHPy zc&E-rDN;?Ehzmpq6eE`;nW=vr=`u+BQn*6_R4%Py5jCM}9KsHXz&*?)YNk>`do-w4 zsTq69!OlovDtJU3C;m*gQWbC9EW&r~SCmd6jJ7H{Iv=O`*5CyRI8IDJrJ5hqU^REO zGG36|P@2|d9h8ML*_F8Q5f94Y#E9iYibmA=g8rMniEL3ule8F~(M&53=lcjzCX*Hu z3563Pn}|rQz!dI8R%uSHW8_CoMM>HP?`X7Ym{N%? zRXICh43YmGs>jo4X%CYR@8GP~ijFcP_4W+@B`v&wCpWqKX53Se%*4H3b&nUEc4{;m4!#-ckbw3Cu+Z1jNlY5l&8&G(i10g!iD_$g0 zT1^Wy$e00P1KySUP{&-xO|^m8PRLBWo{G+~IhpM$ zv1zN|#nX45=?+n3EqcXjgPUkwJEKxO5naW#xI^rp0ADxBG??sQA`i4TO+&v} z0tH}*?l?1ORE&_1Y?zLpvk?qP6WR!8h>?%+yL<`rAp@GqyZFSHq3YFz`CP*x!a}0C zO;|&nkMCCC6KBCq;`@0eKMT!;oOshtF7Gpj#Qw`^iTZ_VV7t(MG&0Gc3l@sPh!|Z(sO~`HVaID*cf@ow@#Q@^oty%xBTa zjz+;c1y{)fDEH>#r#O<= zrW-%Uz7e?R1*k}6qmKUF;duxQwt%)oA4N1iK^+#>AJdc7#Z&kwrw3$#{ru9n!TYd$ z*E;G&^-p~N9vOKg?!{}-ANQu_auBJjp#ZK=4={#$qlKwg^pMe+h#pHMe+9EBY8>SZ zfu?xa_e39<7nVYQ)_guPY%4~+yk3=Y#da%6(h_5U?dE~6Sz zdm;7b4^*s+jq1iI<1tFgPpFc!@~nHt72}XG93@M6Vt7sJ`>7z3P4xF5kV}nv#xN?a zC)8MtnX297U-H4mtID0MAMIv5)n9WTW68{pXlvs#TD3>0_-tqxn;YHfi+hmgLdG#r zhN9r7czUxUHlP$u!xM5l=PJ0#fkV)Xb-;yZ2qzev(0?XlDj+gt*&l1@si+Bz8swAS z)Jg485{@#;p*q`cJU}zKj(Kx!qH8SozaaQUd9a+;s0)8GhA~50L`3OH4o))CM^Ew@ zPohw3$}8UCdX>Z5`xw~RG?;zc>Cso?V^PfXCv)0`c%f!7I&*!3cz9$nUFX_tF9Y19*QdSkR&QY<*|}kTuI!zXX;6}Qb_6}{sAJC zYT7M+7M#Ljx5JtKEcv>SaDr@p0>6O+)SVHkjR^I@k7&?m z2xik7V`KCiSJ2E3sV=YPv?WHST&LI6}F_!XP@&sVyFHI>TSY-Vaf)J~!GPE~-^} zieL67lX zy(JQbigR~wQg?QzoB2JO4PW}QM3HgahrGmtoZw|Mz*TJQ%RTJEZboAeg{su_KZ6>q zq9V)=Wgs&-ATBEM|Kso!9*AfDXQKQOGR`QlhM&oLFL*w6q0uPX!mt|q>F?GOS#oiW z7n}B=tj~a^TH#X`Vhzpateug19z9X-OEmQvZ;XOmuO^&bG!UKaY_5o#IGTxHwW!>4 z(>>;h9!HH@8Wu(~FtN%+{@c;c)MQ!ElaD02&!tcLnOwP;d@_i+OefI6NjL@6H2S0Q zD`NCP^eid_|jqXUj(SiuK87=<@DzQ+@<9WpnriO;qltIjm3j#F~_2E#kxS;8bP|fs${ZmUA&3jlu z#)~seH?Bmx;*9&1K5zvmu1#VkEd(QMCN9KFd_~kJ6qJ)q9!}PH$Y(trZ6jzp(=Sp3CF$unHLE;POrieX zLl3kMG+-Xc+Y72-F{)9W_aA(kqUd5mqJHc5VPxUXv(OBaUZX#L84!P-|7vZAg7A5h!-<&zR33M?Fm1HuLgr-y~ zUBKiY(J6;GQ7J<-i2B{c|I&%PWuu&sU{EI*C(xc3Mp=AZ?~VUVI@2P~SNR!4e>Wb_ z-Hm_HrFGOqJu^--Lyd_>Kb$(QQJsB1DeN`9OjCFq|3qgKWpCq5@Q|wYwf>D=J(@i` z64q!=rXH8*klQjnEDRd>jZXNj(SwR-H}S1HHRLrkeXGC#yvA{36DMwr1>3)9Y7%{c zPO1*o^f8m)sH#uYe}$h>(^Qkwp*%)II%+e$`3|bhU#XQ}84ai)I+K%XP$9wYM^nFy z*Dg&rX=QeKnhLfQo$&*sAXj`Wio56B+tS8CuHO;vZIq1lgtN0k#xi=cDbz;=IUj2p zXE?=1`x(!`KJwz?)6ukv+F&X@T#$8eg-CG3)SM3JC9&=R{oO(kV6*U>&>P*Y8I z?Nm^jR#aAh(g8j&g<-g)a1u=&T-)yAl!oc0$&3fYX8d3F({1Jw>zEplTdwn&OA7~a zhL|rjrY_%OYA$A$s)@e5k3Z2(^+(w*CTvk!jZ;AHBje|2Ds5fPt zXy#Mdxx{|d5`QrhYlataPM+llvyCI$=LexGj#9zKq@X^2C*RL5T4g#-r?8aFK8XAG zg`M+>o%uT2mTLI7=t4X^p5VUpl=}P%IBNfBbE70DPOatr?xTMHi^_j1eOWSHjX(@p zf~!;|s+9cT5&iK|${YQr=g>Fn*}<`U^BM!_A`wFw^KZzczIg|3{W_b(^#H6Zsi zAcq>@-HB8l+woi4^ZzpipYcfp!azC}v|qwoW*Fu9R0HU3_6yUfYxl8|yRnjf;%{Zh z)nlj`>Tz;hK9GVXOr(AnCxK4afwP;Bo@Xy8wg)$rdaU^(rW@SjTR4K%gZFfS*I3RK zNCscKg6q@ooUS&7{g;zHTb91)2h(-d?9TtY$uxj#Dn&csPWVRuo7qc$YQyIGNo^R& zZ(UsEyW@Y>n(A*KYim8-MSsp3sf1tZNO0kQaq)N@-Wfh0P7ddb`~+4KVb+wV*^vI$4=TUkjAUQZ8R3|zImh}H&oj_TkOyTvbJE@l|(q+850o-+TL zE_7w)d4U}NH~-fSE;x)#x`z4Z2r(q)mR^a|ar+p_BPXs<$sjm`i5Q&D19CHjyLHNB zA$PZ6CeWHkS)Or%*B;Naa#0sqz>X4(ir}hvVsQ^XaSCE@@W}7X{pIZO6=0N^@wK-T zCz}}|&Pj^L|79i7y(`sZLo%8T6rm)!cof*ozjz1z1_n!eMlIY0t-Q(toT#cW$r=k>)XUFbB{L}y%~s^LE|gP1smvjqo%34Nd{YXmE35q!Fq(oM9Hc51mg ze8#)HvOp)-n+fDJrd&h8C-w@0SRQ3cMU+rpW?>Gg7AvVebM^O3k*ibnIf#!nn4CQo z&xsOfYB|^r*X4|O%J|?mJLMGVD-Jp-;Hn?~-|N023*=`eeGC^qx71l4t#nnJsaKVM zl*w?W7jxRmVbin<{0A;F!04ox(CS2nhhL&OC0GN0?a^BRyeF%wTs< z3U>)L4$cV#{6F~z`3`zBd9B`_Uc2ysevtlm4WU71@7rm ze;2>kH`h1Bx6LQ}EBR~q=lETLS%J*KF+qE1OlTb})fS;qpG*HgNV0PzWH(Vu~bVjjsNUAD7mW$)LdtM&S z{J5Jul&NQebR0J%C+w7D`A_AZ^0RteEo&ZTzG!}IUIZiKygFYU1Jg3SI!y`TUH}`{&(g{pzKbUlRdi_dx3>BrD`b?$7c_RFu1^CaU@ZC&wb92d6nLu4F zbY+8hcO&^TJI*lQ(U7IgZ5PqezXK;+L6*D;ukRfzZwgwY$Mnx;rdNNGwSq*XGa#Wg zK{%$+VU+;^Oi!m#nfP&sna}s$I>D3&KZMgf?;0^V4^?M(5Nie3tNJhjOh!dMXMLk1 z?|%W4KPR>1UC#P_Pn_6Cr9FUWu4n!oYrLo8oT{(TcY=3a(?gtEaggeEJXfGOXp;}* z$@qVt4GvHJ`w--66CQO{K)Bj4Vfmd3_#!o7x9CKo;hE?M=F`{dGM|Fe#L^w-1bgRX zR8FVe%j~^9$ojd(jJVT&6dnn;S$R4$lFFiix7{A9nk1%uCz;P2625?Dec~A(@aimsYOp2T zgpJ}Q@d2p$Iilw*GEYac)@tgl)o9aZ!*TeP7}#5!BR(gx4uk2Su+qLe8ONc8s36}& zJrM-0`h&*|@gbGeIjN<*pL3fsD%F*rlp=}<7i5TRk*7$7B!P1yl0eKf)MJa_4B6pK zTt_|OP;wKoGAf@rldc{7X$CljF%uHXdYG!R@#2jfCo)k(7b_gC15HI{q z{GI%L{Ehju1&>C=k)rq`t@AbY<-u#CKkW9|o<<(0XM0*3G)A#$4^jG;O|72VBeh@Z zz|{Xz^QNsxOHIq?8H)2kVQ)k45brkcRUW(WLs;rvu_$!u+FU5ejM2K2O&?Jn%W z3D$i0aFn#%Huo`KS8J=uct3Pi3M*0A-&tUTR#z%0d0?mpjz_O@hp3^^cl@ggZ&1GHRhu@P$Y7W2o zrt|{ey8Nh$n#h@9_^g*kF`@jxtiFX9VbWQGOgM_zHjdafjTzSoxE3j3u;~QNbf3Rh zGU4GKO{H5bNe8_HmQp`BI&r!RQV@f;xD{qfYdY&@%u{;8kf^OIoN%)pFXnoh&P?%H zOLPt=StB>_$^VGAX=Zwjnq-Aj)UR2nxsqWD6y{TPW8YV&n~boJj^l0I zg2)5P1BQjquMnmyn+zji11@o?qD4}r7)iq6W;v{ubYe0QXDXdY8{*hZ{_i?6Vga1D zzF*Z5?COeQX1dR0;R#jKc3}t6={sv?1z%sG@BKt&R)qb$h)J=^%s3mq^xqv3H!wGA zFV>=>CB@`k*^QSFW_%y$AM=CtmnU# z-bx*!RDYBqUtC6^TaHYF%`L zGcZOFr+&hnv7{cSod{PCJq(oe&-G^a+)urdl0IcsaYTWY(MUz58etw_v_SIL8fNJ6E=7m41aTqzAw2dB;PZ18UO z4f9(9;{s>U9B;>2AY8?KN!~&5#u!^+sIowO;;}BUboF#lD z)Q5VnK*$$Nz~5wB@NzIU=nT~iwFwOiT@Garx26J=Q6m2rv1z};ElSaH>JB{}JFlEx zT^|jzOr?rCY7}PHU71d~HyppZ;&CyDG*Y@G1*D8{;fKik2!v ziYt{{ij$Q%O}(Io)hgz$=KAK$=BSGL4kd7PHA&fo1~P=sYb$XzKUKYnwfa$djLv!w zYU`oWZ_*Di5)abZ++kY4T!@~v8(C}M9`nr(sw z_p?|<%ns}6j<6fH;7H<2E0}I$IYoL0k>MLIbhC*8hFC=E0n2y+D)P2O-Qt`Y*Irb} z;Pc4z5~rKLq_+L;GGiALp zSgEVTDDm>$Grt5esB*|Hovc!&(C{9aL+gHMMp*ky|EEQ5nevbFTO1Uwa>K>VD z4%edw*P{Whs*CvBe029`ac%fQ-{*yKxu58_p7=Lbs!gsMPrPcxHK|Tj*Och0iyfs$ zQagDMI@^!#}|v-;l+uu%lXoA_hm?8KT$V)Fguiz$0jH>7Pv$6eWX-dDlD5f8-00U=m9 zI4HO)_$l~9Xi#W<=v}CIcp-e}MUgV>g56B3JAq%6qlVrs)@83YftOm6&uLRT<82yN zOPU*-|2D5gSGyL){$J)_&27ysQD{s zvN+W7_K4b` zF?GOMJp7w3K3sDzdHmjEUmg#7; zPFvpKN|3=?$yyyvdN1n?6viuv(WCjgFaE17adpjM4O`w>?pfBOPAq9TVs2u7gU0C% zN@=Guj{aaVQRy{ZL3Y?#bLdL%!#l0YlwvRFR25LryHqdxwVenP-Q_g~s!kfdB3^{x&Hzm1p$APpbot7~{E8heNh- zZM16F!3+P29L8z1gSG|Nf$~g!59tM{Id{S~DFu48o*B+nwm-dpzI}o4?=?$FCXI!=`HxbjF)FoAw6X>DJXXIv=-$FF=Uzi z8w#|$xHpePgOU#}pNd=16Y=r?@y+H^FFZm%NCk0Z_=D%J<>ySKE*l2Mkxl+U%nEXf zM=D6xSl-(O;%5pjF#s%?!(KKF?^>vaWV&yvY+y=@(=}rrgT%+ zgQmPuRaAz49(V9?IflDY5!Hih@nz+j@<{oJhvtv!D85s|JiR*fyrV|Z6_z6=Z#4&ThCFF0W!-93ZC!2u+CJIL_U!iJ_Pq9}?V@eD zt+TDT?G2vPWpQ6wK~3qhJTfoDeT$|Rn=fs%urB@(L3y*dMz)m5-dW0^bg;? z;P=CsNQ4dAnOX2jZ3?psFH?q|k!&bPuQGLL9R7?dy&+g-Ab2BqEVv=Kh)@2UoYN&V zIkb^@<_)C}mkZYo_eF8KExZN&S7Bn*az1%3e%>8A**`%0BDKNbYb6}q(*YXBPtK_ z+6BxnCw1|T=x*?+t8hi(@PWFG1HUpyAHy&m2{Y>~=k}JTJ{$>4^)S5E4w3v3FYok4 z_+2EPE|GtMAhzcS;3p^^P)QD)cpQ(w4AADK) zXZaUm)m$o-ORVfXR71ZLt*)?-LyD+oRC5ubV$lCZi4V7x9jLKp(AVWvzVQ5Y;6%Tm z1T2HXuRQ&GSNggc#GQF^4{Ef+D3QLq6V;Ox=?Jx5CA!xn`rB31k!$E$vy!>ji6*Y> zT;7cUN>C3k^bBYPAHaT^4OUPGXQT8)xd2+Js+@JTfsVAZd{eHY+*DetPu2Q3f?YQo z=5%y27RzVzQR<2EW=(yJ=h~lYL!73v@xC7`9~3iQ%VpIzs1gt26!S&3nO)`}U(G?? zC~&TEKP6TU8sI=PvC>UnE#URC*N-G zU*4Z^YG}iw0*~C@&z^^#U7o(4oSswoz$B+GN&PYPY)ZA1gUQX4FC{fh@+9s|oRT<% z%ItMw`=nb*^^&EO$0_qtE2SMuYv;)fvK8?T!aXymzlVR7|E6CJ#0H#!(t%omPJy|B zM}aErp;jRw{5gD+^O+XwV~lS9pF#e?G;|1=EDtk{|D-GQ=(nkX*K-!aFH#0D0x#Th z4V?1(JxGxn$xEnet0^Otb;<#BX~&c^Am$x#0Dda>!LN3x^a&*5HrHtrSvrYb*Pdyu z17@j;j>yN^6|dncYyn%F&Z+CaFwHtk7Vb&5-lVl-K6xYZH!Ht#qy&4QCM$k6UUtcm zDroHw!1pe|e%_|v)!krSE5VLsxHZebTj1@2w4@jQVoFei8sn8%nOOT2{aAPUyVv-D z)kFP!SV>R{sIAp?MDYCPZd9K8&GF_E^ut}~jptgHTeebj&ZOr2%Tn0#!MumrL2>g- zGG9-%iW=tgu2dG0(VK$6T9w!GQT9#`R<2pzOKiv_T>|@k$4Yr@YRs&=9(>Jz(FAy@ z85e^oeb@h&27kJ%{R`%{k(zQCl~g05LSkfZWD;?^RK$V?_$cVagRqG`-+*W^H*yVS zPJTEzT|li4aK4U#Qg=Ma-fK?OZVcA9h6%8Yrfn7zx7(aCG7{EZ45xWC1}ExD-`4}A zyCO4d8`FvaQ@xB#)l}RB&V&5l2TwZ-vVWbg4^rctrfS>886e;1aV>fO-{+aKafAq+ z8LjABYN?H!aq+X3j#cgEOi33g+OxLhV5J*Ss%ja-lTA|dAHDXYO?m;>cO*ug>&S$K# zo*%J>=f2^bF_J6Y3r^@ipqLgscY1+^uXEB(e6V@J3uL;n3xc3mPavHNH4$S z6O5Po(XIY~L)#CMiP_;+kh9s~_z^IHl}tQ>-2bUewJUK>%TQ|h-{2m70{?!&wCc0* z490Ocm^=9+w1$j zS(Phj}_38R0&IQOT9~2v?%tC!^ix;)`5Unq4}%WM}b7 z#q*1vEE>Sv{rd~gENor)Q^Dm0oeOFetjqr(e@g!U&07&tBM-VV?(cEZt9Qd!s+z&wQ$5?Id3yZ zbx3v{#(PI6(bxj??^yoP=|bes`F!Ka#%e^&`Wo`|H-KO_l0~|iIM+Qyse6He#rRb_ z*lP1_5OL>cLAxI+GAhg2kHA{$LpJcGVdU>-;(E0z)?w{UD}RPnnRLJ6CotvNvT5X-{8(1Ed^rE5F$<(=WW;tYw5|8j6|4odCu3Rb14I_AX~|7x3!Emm+{X)RT;TN&dklaoUxWiiKx9sWdCC< zs&BBSwvZjrnON=3{2$Jn%xzrFx~|!bARZtV)sP-)!%X0Q*szzf4??S-!Kh&qxU?n~ zc{%gIYA{#0XLeuKU+b3LgS{mfaWZ!AAhId$1X-U)7T&=wdkLNTICjC zWnTI_%p`mXoic&k`GI6gmoj2K1MKr(F3mW5noRH88FjvZvFmFXA$T$wXL}WW`w4is zn%-JbxeFPr#`S*$F>e9)-h|^$0~cpzl<==w_cHRboqs3k#mG-{=GIk`y*n2F=Y2BE zcQQ&|v+4!1Dz~8X&S9kLeg5I3PWke(zm@f8O`(rU$CUOeT~=}@v8n$S58&Se&MI2W zs-+c-IrJ>pod0_MUHKP*kI+%ZvIIe^}g$*iP5d*Z42=SIF#o6y6+(JRMF;my&IJJu&xk zRt0^X@v9RUby`Ml<713hcV*t}tn8XO7t$l&vhG(qdU*u@d;SF5L--H<>FY+-HiMII z}R<1O7u^I?0wj0-w`ppjaj$dnP2!iJbhH=24;zkVBG8gId0>b z3E!J6*qvnge1T;A3wvNHKKd8bZ5wlI4g*`-R<#F3yRrR|T!>4E&EJhJ_`yU}s=0EF=5>I!3yFBIjl#^_T{>uO?HZV@?NFL%tYnzl~_gSlaY5*1_MX z`KviIup6ckk$HhLPs3q%<%}Rtq*an-@+6~Ky|POf9k`E}Z{S~Z^oBh$w+kbvWu_^|6R7SY;9TX@-wj&uP=WJ+i`7qUin7!L{7PHlre)DzJDPjwl(u3#(=IB)V@*07x2m%<-4%pp5lLpZYmpG zHlXZUMuA>p)M9N}V}2XK8ZCb*A6Nd@@*B&~Wa4Mw2~bLv%VgZ0>v@rWw)^^BZ5@UC0sY~tE`DZ4KiT?=ox1~u-%+ERB? z;+pI-#=uU(HaUx|k>)uCwD@nJwz(r$X0>EC(W8v8WM%%wNd3F4!|@&ZuSRBD_;d)7 zfzf!i=X2a3;f0HtT{(~0_N~b$c#w>o9+kDoWF)nwqIH6Q6UqIqBt~`tapq^qgPe(N zegkt2uM79=B z%xCD)Ygt>h38VS{CAM@o(eM^oSKtO$=0`n()|$?LT&}GwtZYjD=MD7L z2aL&8;pzVg&+rq*-Rgme_cMBT07M_kYE1L9*1`9Ovl`)9*az36-6yi*^aqSi?P5Hm zHhQ)d8FGg+_MgQVX<_z$_--M;zQvf_jc{8>BGJo`lP4L8A4a67IXMWcGT$N-_dIf- ze`X}$Lgpy1$D_P~C}$NpT+cB>vkxm#uV(~xdc|Z=<;jXCcz*y67(?{_Z0xR{743+g zloG)|fZx9byKh~2A#)qDnKj-UPw_Ug>ORH?uE!kvJK(yx=!FclLPs);yYZj5CovCe z05<4gqGFeWVIzp;-<`~Km<~l&kyBg9_|1_-dQWB^SU)_MPUPoS;BER3O3#6NqsZKK z=4~-Cixp6RK65D-G7n=JYZdImZ`jGUi~qS@%h~ton}@OC&ZAxJ7yf2(KdVF5v5X;)V6~asSy$x| zVuydvdY-j*J_Hf9F7sF!IG6meqlhP7i7ogv*5MMYvTBUV4ImrycDVjNeD25D?#27P z4!?USp6GGJ9DBe^t>C8S@RPjddo6M>%kZ+cP|6&5?-9^?AlO`&)el!>EoPmcnMm4e zj0?S&^$}|ke37+)TCCx>g>dg@{5CGhercPvowhzqhF?Fd<$dUmUl_w)MdrX-{y}mN zwsZz)Sj9+=D+?Bry)=*f@-gV^Oy#-DaD>OGE`eS_%5?5h3DX(+BNt?a<}+u`?hqoI%As zSvjsPV+Re28WXoTMw700*!_4>h$pw3Y zEVzGQEq+P=bp`?MC2CQd`FQ5Yt{@Y%CtA_jHqRj~FVGt=@i~di_(^OpAu$VC^=)Tn zL$W!Cp!X-kf4hiPx5H|<5Rd45xPB1%0qx;WGpIh!dI2dJk=2cL6AH&yx`4ov@rI^Kou>w%_S$f)EKte|u;F~a&4`9#`2 zL>E0m=H75})q0h;;{QEb6CcfF4n}p3db15GA5GTGG%^Cd0U-|~f3Fv-YTQgt%D*b! z#;>Q3CG#q3Gjo6t7E+XxNYXxVX&v%4gV@nk#D7|0_s*feuEC3HQneS{ z{EE!pcbJ7TozE|^Ay$Ey8DtoDL)HdjO`zy1t z4o;)Ae`1YRGRt`+p8QH|m~L2tcY#}H@Npl@zKvM`C*%J%!P3gcGcG6Rsu7mg zaB_5CA@a8gi>+}^BRtpc_ziVgqj@#a!Ngq3g-NZ2kqzTFI(>XE8QXSiFuLu{Fh;iu3t5;BJg= z-pbhD%j8b2EZJ6)TY6Y&PHCOenx)08Is1Ldhh#FmOvb`^*2$6OGEf?jrEouf z^MTU7M5C9miuI+)#xC*{ZXwR{Q$=;;>_X)2Ijq&MnOjhT25wl@3v|B(-~CbipB4B* zhhYtj!e;pb?NALmjU;nvJl4!Ye8^?s*E(j~E&{`+;Yr<3uJ}3V;-<_r*^7?(7Ck+m zNcuu7?eCGRt@zGsk&pEp`%m>Vna?qr|E=$pQHP9;1W_l~h@A9+;XufOE>aW5@ zYtZHyN9f0nzZm}Q&6Kdl&TG-vKX7F;<{|;)`}H}j$=mQNObO_%)WRRA9ewF@hzV0Cuq}W zID01)J%g({GT(nIIoo`_5oH-dM;g@zI%e#V^!W&p4;xnS#lZfZshdn%me8Y3Wu2ZqwPG$7r z++^lWKWzA(AYUEkzWmO-kQvE1{BY(RwqOm2eXL%vkY4|U2-5rLqfe4~aSPE$i_u8S z@nAn9zBHaWb>~%fK^w2Cm__XUe#Rw-lzFk>X$VgXh{=KX(8Ch4BKMX2;S-y~US2vY! zCzi9he0}+EOJ&PRmY3#3-zsNY@tE^!AF{{^l&rbe%B(tIc-qG<` zW0x=<`V=d3E~(gzpV5XK=0UXJBC7OR-3$-(!9rh6%wjxt#o6?2e|r6Le4&4XGWD=1$1w+|20HU0 ze3Cs`wTW1tgwN0~yDb^>C3pvK^2>E(k#&db4=AIeJ=6@9a?r0nKPsCzz5)m4+8Cn!Oag5&0mT|(2%RI0C~n! z?i)N#>*f1Ap${=!KU#dODc{ zeQgqp7(%@UAT!EXQ}o#Z^xp#VgE zuQ-Wp#b=Y_NsNR|B8D;r|6wLqy#Y2&1{20I`+9K2Y2a3S&?1`*^A`Q8Fyi$JdF8IpXeRMBNb?hwlkuLOAewO}bMHnnljuy=|L;j%nv!YEUwpIIBde(& zcrY4Vn29zlCikd4v7P?pqo2dtVy9u-ABD{4pf4Q({D^4UbHqOG0bg%nj0Z%wg_;J#z_m zvHJHjtncNnn@#3a0Xi!mxz9uXH(^n&AWHHzvi=R%EFdHPXJXM6%=>E!5*&q|?1$bx zHS3S)-jkUtcoe$19-4LwcYVriou?TIzLl8G#YAp~Cpr07VZD#VTl_0IXfGsVdT-)? zPUUz4ICv|zR$tbe$p<+;2l@U%y&ptkY|WEM-*o21E+D$FA3MK2cIZH^AA!Ys0Y@h$ zGFu4t%mFq2ij<8&X3oTKJ`)-ZCZ>5GCCo%>x05Z>9i4j>9@TiV5+@Q9dH}2Ua?bU^ z>&wc_;~8^7n3>dJY9jIP5II?bWy@LytZQ&IR_8fnM-9PyJd-CMgU{HIF^0Wh!E&(S zE&T2$dEQOP%wS~jcr2r1v4~oc`PhuuM>cX?Tvb3!?k8q$F2z5cPb_2(*3@ibac?4@ zQ_<#+Gw<_$VtBXG@3&x6-h$0Fif=cvyB^;^8E=`vec!_kYl!0gM*L+3WqrxG#>>Q0 z#*vqI7x9!)+~M7$K!*D`JBiX~VKJ=c`DLtv(THf};jE`un>?=F*xvK-gr3FTST7I*j?1B{pLhY|4xH?Vr^1d!Fa~xD4iaXE2JjnG$DHqkn-dH^9foV?nmUhHO9y zb&-y4=-9IvgT9_v<0Hv-AN9Ev>^T$p>584Zk69kyVXwS_j(vhr^k?~;fd*ZJFJB#r z=$m-Z*V3LFsogLz<#@d47GPj0cJ(T(n>pCIAK=@~L(&|p?1+S20}aPhD`&2~18qN` z_3u&7m!R=P>N*y>+=4BA8?l#%ppLVLKS|~f@52Acz=Ewo6e9;MpPL-lA`VjzyQ?mH zEvQx(+O=iHrJmsTDflL*fS>)ahTDUcm8^*S3yAwEBTa9B%l{(MHHB!aI5`!hd=F~> z$XXlS#M7REws%15!Sq-+ESzrK-2ucI3>V$O^XHRAR)*!?6%90y8lOazsT(z@ zNl$KtpOz43n$Gi$%}q?QJRL8a2gl~)Gj<@da|*mY9Q`tceHhltspzf~INy;rG^G{Q zaamxFj4VF0pi&Dc(g7*x4Rww~o2l{oafi9be;-@k+_ZYLAx1u_%A1u-_E|BK1@$|v%<6K$;ipO1(3DOS}?EQUAO z-s5-{*7YJJ)irSHV7VAuJ{j#l2$}B3H?5gEnI-(b3CraxEP%JMBGmhDF^2v*Hq6gF zsf?&h3$UOE*S7;to3K`yR>x}UG7ALy2Q?cD0$dF~Ur0@_M~goILcdOV^Ra|h;VJ*h zz8s5THswAK%H7U&L+Ov>Y3I>I1#R)kXzxp*$pc*R0r#!LYS~BZaUb^1dN64=7&Vz+ zujeVw66glXGzMi_!IK?`86KC|08;8f^uSZtP}b{i<`oR#`d;)&1A3?o?kT2c`O|13 zzGj3UMcDfJgpA7T(M3ufpH6z@8kG|#Q2)lXY6}?k7FPNl#NEy! zN4YQje;VUUmm%*)S>HyJE}|WI++WB%u3ez$_q1XPV|`bGO1;p(rTEWl@z^)AZN>X- zaM1f60ct!%dnP8bELx96Lo02~uy6A4xxd5fdW~_C7ul!dZ+w}=0t<-3v>*b~8woz0 zTAfEr25?so%B_d@kp~8@$4g(pYFP`pV;wZAz~1P>^=Fb>Itr`mQJ(q|=m*6LcJcqIurjssQMbL_yPIOr@t!`>8wN#?FI{0qKD>F z+AJvg1{U1&v}-cf#w6%JIl-z~=$a+Szv~YbGfNMd-|~jy;mQMtqZQrc^BRAChh(={QNEyoeeF%gH}Is{d(@)M#~Cm+dimuAZgtWxMe3t zd7Lk0?5Y;^Ymd~Pgr|Nnay$x|)hc@u+eNvX$njW4hpz$W&Ii#>#m?`KG+lrk{+ZRA zob&xG*7o}#mg6_S;?XGQdl<VpW^1*_+&3} z-vn&Q2S9)^Ai$0I27d+@uHyZF_`C)`qO0KHyDd~tKa zA4}niZ{eH;@X&JjWdr@ZpFY+O2%fA9zgNL`yWpv1SZ$xeIn%+vSKyzwke|8m);hSq zB*8!Hkh>pv%4&FQ$HAvGfsc;ijzQ?vOB2m;J(Rf|It`}% zeP~H5;rF{P#?{7WNTnrA(M%KlR z*RVuhM92RFJe zOe~wa*l<+cvy8sr$#26kukqZe z@Qwd3B;G8d-M_;F=Hem7MweRD|*u2W7v9w566HQqD7D7p4Rk1 zlSI4e0n~tUSy<*diA3dal*?8fy4B>jYW(V_TXoLMY5biKv@J|zuP|wu_SFvhYbWiJ zN_+TK?NJVwROg=1b4?O2@o?(cly+HHxu6*}ZpYR!(V<6!T_@19=Yn;^LE-B`;hPgY zyaQb_itkr*ekh1L5afxL*`FP_zbjjJFzH12D_AA%FiO%w zZIkHwmK--gLR4>r)H8Rw>go>7!cQ>)i|& zCr{l6kMChq4wb^%+@bf~hPvCb9@M`Z_OY#NObONL-3->r+)GdGfR}%T-*-@k*k3`Z z8T3ysPaw>ha6u-kLYGhF9PFmQcTuh-=kdOeTJ27b zmBOvb{puc{=+$ChSQ^DVR}LvpBs5{Xga@su^^g~Bv31J?<-H=4y%HL*SS|SDiwO<< z;`LR~zB)ElO=w+@R!a~0O0TndLQ8qCKE2ZbzVkCY+*-+9>OX(+zLtr#)&Y)EzjjB8 zy7SosuIPxgDQ{Y8_K&-L-I`4cuM6^ZEPT@klsOgtI|Gi>5;}`d_4|pSb|3iZ*o3Q& z<6U0Y@B2e?$0?whTKtTIcZzPOvG?Kk-pFq^B)%hZA{w;hy>n6@?aIJ}&YTe&y0LX< zQx?<@ow=tKyLA^GtalUITN`@SNjR(yG?Ry#Q*sBc?+EvFr9EA_`$$T(BoQ+ZN3IUZ zRfC`WsE6PtXmMX@^3kKdsYzC@FK!5*=!Ih>gX{(nO`KOh&Y zkd6)9=RInS4b*E*A~CCZa$4J^3BqgDtWWB^nWw223KBlfMha^pfg)}eZI{2x+0|i6 zf)o>htHY@cpjAWqOJ7)i=)h+0_}PZDVyxPrVS;O7w4A+24eSwrQU0|MQp&93 z$ltcnOKL=0?@NEkdgW5j9F8+-Z%OitbdXP!^=#@ZpVWh24^Mbn{_6mD%3-Yg4^tqDBplf(JwMdCqGNu&JZ{_ogjS;CDW^@wpdFw6;ZDBj4&7 zF?E4D`2;P|qU6^?C>Pwf9n6y>wY2t9s#cS2sH7IPdA_(+8~MxToUO0mDy;%NOlkHT z_nI%Tou_Z*dmg{0y2iR3NS+uyB!$CP)}GUD6tN?Ex;~+|ZP`Z+)fu+HR|Vg+zrK9PQ=0Qm!((RV%^9rtnr9sND`acRq-a?f53AwSeMciZKyw5o0tWq*|~o*B%KD zbZ6HSR-1~a>R%B_G_nlul$*tD(WNDyt+_(%=nVF#(YjJr7q<3nt++y5Z-^u`Ot`!e z_gRaENl9u@?V#2NcWbYN7^f2>%@mTeAmhdE6h8r zwKC*6H?LKvR)<%`3vIb-oT&-tx?6&J)85nCu>ZocJdEFJC)X>}`U>i2ZIRksXRkHl zS~*G#lLq#D3!ZGhs)-vR!KuEscdca$uG1DaDs3NjNZPNKmv<~TEV)_m`YsO9?S80W5|7v^0=KYn4Ev&ZEswv`I zK6@Fv{%I-lR+7k?@7l_uo*1JQr%jZP+N|ZwJOnCKY-_}*jKXu!frVt`p=Z|bMm=5 zIa0fc;~PO4(I~a{Z0!MBrza4b2&sRg?hr}BPPMh}(j_P&9mVu=D6bD+p0wLG@2AB( z*lqbXaBm0izk-PBRyFK4P*L5nInmL^#jLlMf$_iPw0aq>486WQ(Y-(N+gj@H9b1#q z^C;cSD{va8&Ay3l37wqup!=_`Zu)|4wbSYPLfcxng@#Yby(HPgXjFFmh-!>DzuhyQ1ybnu7BXOjEfox zm2P?iT3>3tt;xQP8v0EgsyAUDY6)onsM|IqefkUis`cib+HSjP=N@XOj2H)Xx0dpX z?;&v!rIp6DlRk+Ns|FyPh!j??n9&^62y_zB8elo-DI4+fN-g0yQjdsAF$Sh}Y!BMg z_L#k;y-=BOt!UwhLILd$X;*Ib0j;fgpe>=z;+}2^ArHQhhD^D>%TKkaMPoK=E28UZ zsISB~HG+E32!=Me$DtL}CA~QA#=DlfcDjDPx<<|6RU)h9iK=P}b)seZ9=bzas-DoE zQXe4`+lJp7QR9|u>Hy1CYlkLMlSp@c4$teY=mm(~?fFzo zG@=wWO++o!`u2+Vx8xUp>%{$PFY9l;4(C^OgVFQD=y%88j4xD&rrNZQV;ae@?@~KD zq8jQSBa`-@*d3VVk%*kZ)pC6)(frz!Br++!p$#nG`5tHGcD1O#`Km~GF62_l zG%8b+oYT&;meCs9ZOcQB99bw%T3*Dv*rSBGdB2>krV9k{De6%*SV}piGBl>zOC)d$ zoN>&;-&v9~*_2yREv^xlL<-SK4=r@uD6w6JIwk618i6WW0U++xIU9Tvml0K5O3M?$; z7iku#?XgE9gS3@zL@c>Rtk)*67aWxgYODKwM(82GiLFt3ya)1yw$#E1+d(ZW_3bTj zGM;M5;!FCuQb~_Y>IMc&l|buM*BZl76Kic~f#@;o^{JCL zCGUFL`r>+b+9_&m@mzf^J?#gvJ@lB*xBt~{>fr+Rebi5?%3boSUXd~sl2s3>(yLSZ zYRM@*srD7Cjp%rVF%4stW;3LD40_C-*FN;BR5OQO_Pa0rqUfuTICXX^!+whYv5O2! zSH!`^2m4hCE92eoQGY)pP8B11YSzFWdq!D}J~j>)mPSabWu&i^67k*V`U>CGtF3X= zCjCXk(b~|*)qbx@tF`_`baB+Um3F^~8?gl68-lFZ$Q|{qciSF9+M#a<;f&OZV7HsMcb+RbllqVW@^4a!fUEU)e5!Sn{5OcLxLXPC^ zum_Z%u#>eil^?C1I3s@ShF5=s`$P%lKH~4Ge_C`$MeY8rJ>%9loE^=S>{|4WBRcNjFZd6`f5%!~$ zP>TeA+S?HmvG>Jqt+~Kh>ufvRJkpbhxU6y(c6CT&AgI!>{7bJ*NbP#wBaUVSLc80@ zj`p)LiS@~m60Piun?^}Q_bYI&L?89bQ3ri`x9*g++Wy{Sx{}j!-&6Hsosg+1Q>)>ro-km2zUP zFS*L2uyl<#DwA3f#^L2Qqjb{L$dJ+6Z0Mk$F4h<+&`0t}jF#3ON%dU#-0`-!MqZY# z5!;utmB~~6uI5ySsS~6_jIyY$)KO{=b&?zun%YsPyn|dAUWVKpvEPUZhv%rKGOlY( zIQZRnEeWH(VRwiOa-F_sjBIEtNPBxCazDZ&-_CVf|HcU9R%2!bd}@8!p9S=SHj;4w zwLn-^QanbBL^QnvDIK<)6bN0Xe9M)-)O~89zyf*8sJ-K9W~VrAp(fNS41ZK>y*|G+ z;2OC!bf>hIpKDT*QsGk%>5TAf^a{nB@KU4SMPB`&wQA#v)`c*Rqr-QE+L^5BmNExgvKjIA{$9oD;7NCW-9(0E!15!X?}Ndrd}#9ZsI z&I_AbsrE>T)=#X*acIS`*L@ExmOkF)cv;}G{44dOhc%C~!d_3$*y`vXr9(V1e?IZS=dX z_GO=of0YRbB&TSxJS}R3|0C{3S)s#}_VDG^?tyjUQA8v}e68C+Nij?R!QVtRV??3h zlt1;Gk|hOgjrWNg`x0sgepsF*NPo*wBSwTx$&FG)-LO2xL*>^#jP|Ac8{ef(I({NL z2A2evDUmhdAma&Io7w`#T=j4pbJpmqyB3cT&Gc7o0_E0-rxG2piN;*(y~Z`YN6S~q zcceP52^{d5;Z=sFQgXE#)Kc=kR)yz81GQDU*{*aPu9Q|sf6B#&(7`^8Xh!)#+eZlu zZ&?ZSr5uO79+s$O#z=>~?{9%}>Kmfa zw8upYdp)Rb4uIE+Gwz{5rG85Lh$l&7U!s1{P4o{R#PiBWP&IrC`$3-c`tTj)-2KTe z1@r*rVmI3w9;)pRjTF%{OSJU#WzUFZj;h%nF;XrIPSa|Yo9n@SkxB13jhhyHjM_N1 zVBFq#jS-(1`G^>n)_>|dIF^$}vmC{6LaB89h*Zkcy(2 z_!rz0ql|%to|hj&m#WK^3H6%RRCdBY>LIO2eNapB>a^Bs*N7a7q#>u?CyItv^_kXI zj*r$U!=4l0)Q=IHRQ~jfl!)R)uQ<{W9>J>3l-J>DPta6((DLz@RPP24cwdTM z+8Uu(EZKezU)*}6J#9_2bCm*1vt%s?`zLTwD*L3={s_I#Wt5#tiRDKRc%8kGYBoRB zh}vPIZ90zcRh|#|if`T*+$%2!S_dzOrJ>&S3R@_Tcz1A)2pv|4&oMe}Eya_#)+5j8 ze^_s8DJA8)L;kAvjrNk8(jL0OYf=QW%qSQdY^Qa zy5eBiV_|bipU_l#3#k>WcGI4XSiJ9g6=r8eewO9?6s=vK9rznME}rIdq+rl0(AIBq zoDw2mMW01Gt-aLsp0tGEGJ7Xz8+{sWh_*;AOZCq5Dr;nGrBEuL-XHCZBTI^Bd4+Y1 zvg6YyraT|~VZGCr)|EDuTKYM_wB}(G zg_RR|e6~+6(P|2NNh}n3)neL4+P&HgenyNs@>#_503Cq%}^c2$4Y^O32 zh+f6Bv={ZZy)u>cR0Tsm0C7(#2owy&3xC0EM)MlX<xx6=}G-G4ZOkT<5dqeJ4 zQ>yXAN&Uy@M|mga4TaYzH^peF6pb3$D(#mTAG8L6S3cQ@iX7`XkzO2DTiFin=D;Iy zCJ;(~4;|rMqL;j_*Qc)V6`DiD3oS1O#wOm2h4ECc4GSo=pfr*K-WOMign_hPn`Y$r zT%QtqV01h?a70OtOaNt38MXFega{>uq~j`qV*W19M@iyF>IwOb@b$f41PV)C{Es+p z)IszMEDqZ*v`!$DrTWyUn>7n8cC=0GjR>}g7ygFv3z1l>S<75xHqIO44&m2^%^fjD zBjk=fSmm>{=*6~(Z-`%FvhVsTUh5N6eW1PWFHsxgAl4(IF8Vy-Me0j?r0)|E z7Qe-4i8D2f)yK%Av6qM;S+|JEXmcBlv0Tp>lZkjsM6!*2dbhurJ!0I(XqV9{_sChX z)c+q5Gd8By^4D~fIZ!+59AyRdEY7Eno#1s+LQ9-wulMxz54HdX5!lYK6 z7$a8u88OxPRCD-b@!OizJcyNXrS(cB+?I(qp(}k4zr?t!rHWZv_?F^5fo74>ambbw z8*P8OO9#J2>>~9XLSL#sLKgQ}o}@2uS4QI1XpzDj$8IE>YJxK4&>G=3#w8pXlc} zs%$G_rlDHEx|$~xqX6C~T|z6UGp$EZ#VgdKYO&A{G0LE3@mxf@v^wOA(9HIN)RsG9 zTwX2?|0*J3_M7zg8vP1;)?f5$wHuYxeAw?~y0?$i%G{qgi zTMz3Pb?|v!>nkk5w8nCHl$H9x@~%{no25X|Ik?($fnL@>EWE%wsT`6USEO{)&WlpQ zh6*Y2oHbFx!b%Mp3_Dr0l~#eqmLy&)gWl!0c((_<-%#&KnfHp&xR&9OTcD=b+nV4juMY}I1ItKx!Ef=3 zJA4ms!B$v1pK2>YdxRA#RiiGR4g3k8|B$sA5~DPEkJhP}61KZmv}Kr~6E%zXxH=Fi zMt;02a7bB>aiqXK?K`!ZvSw?fec+`fdrqoEFWAaMS9!J13tHI9lmgLn(#`wQ2!YpG zw&nShxZg8ABX~Wi9z5c8X}?CSDYTfq<16IDQhiFq9xN+pB3DQi&sd5tpBOg3RE@So ztz&dUibxCVA2hJFzQQsPfuf&6zWvQTXcm+S83~D#ekm7PTg&m~SwF3r_XoFoMy)IF zDN$M#>VB!A?oRc$*Tr34YnjqRx>}m8GRhR3Wy_V+c!^!XGtrJ{X+(eGj8v0*thKZV zt_xa98Id>iquxvKmJ;D^$!_w5CCIC4DRoU~L66-W$M>vUZf#S~N^Pgkvut&ZTX=tZ zE^=^;W~aK;ES<%-%elG4m4 zhsT+EqET~u&Fl3i|1-+yW$DH$1{A6@+0SpTaH~@tEi9fK`UDkmWTb}H~nB+ zBK@P)ehWX?GJK*O=T*TG_Lx27z4o%RGn`A|r#xcI^|Vv^q*7oFQp(t_ptGe~lAEPP z{i1xS=siByD1$s>&%4`h^;gVO^qkKuN)W{!_oW3_kf1|Ir84ckQYsK2EZ#%7VnkM% z7xj$w$S;9r#(zEX?$FSoHDeSYtgP_jBYWH0ik~6L+J=Dwp}~}P<-mHSJrn#8be1+j zp{Tj_kpHDpaF%fndqNro-J+LMiIq2lkL7gnNY0OT`&^F#?QOlseuk`xm>!8~QBzyv z9`7MXmMcZQDrjR1?Fnfd8kItMrTW9?`z@Xvy38}7kK(HM<~4C`s(-=?3Y}${Q9qyS z-QnFS+3EA8itV&+@hO#p7bAOJdZxVT)zUQ(PmdtHhg3f8m6&TL{R2DX%0S8Zl-^Q5 zBs8w~9;Hw@Qs(Vbukg9@b#RCj3r@ENQ49Ol8U>DrBr5qnO9_r1mChj{%Cd2%kg@0+ zc}{&2HiFtG^h@Yldn@+;@4;EK;91|rlc-~y4`d476d9skF)u7QH)J_@I*>>j*;Z*O zDulKW!=kQ%3%1wmq(k~ldoQ>tWZu@=meBvUA~-T+E&A6s2iHcFA>M-$a!YDE9pbxG o_lFcnqo9}f#C^Ui_vsT;S@4OLpZ32!YY!>CN@lb_T2xx~e;Jg2r2qf` literal 0 HcmV?d00001 diff --git a/sdks/community/go/example/server/internal/config/config.go b/sdks/community/go/example/server/internal/config/config.go index f10d19bc38..7744874790 100644 --- a/sdks/community/go/example/server/internal/config/config.go +++ b/sdks/community/go/example/server/internal/config/config.go @@ -1,227 +1,93 @@ +// Package config loads server configuration from the environment. package config import ( - "errors" - "flag" - "fmt" - "log/slog" "os" "strconv" - "strings" "time" ) -// Config holds all server configuration values +const ( + // DefaultMaxIterations bounds the model<->tool loop per run when unset/invalid. + DefaultMaxIterations = 8 + // MaxIterationsCeiling caps AGENT_MAX_ITERATIONS so an accidental large value + // can't run the (paid) model an unbounded number of times. + MaxIterationsCeiling = 64 +) + +// Config holds the server's runtime configuration. type Config struct { - // Server settings Host string Port int - // Logging - LogLevel string - - // Transport settings - EnableSSE bool - - // Timeout settings - ReadTimeout time.Duration - WriteTimeout time.Duration - SSEKeepAlive time.Duration - - // CORS settings - CORSEnabled bool - CORSAllowedOrigins []string - - // Streaming settings - StreamingChunkDelay time.Duration + // Provider selects the eino chat-model backend. This monorepo example ships + // the reproducible OpenAI provider path and avoids local/private providers. + Provider string + // Model is the model slug passed to the provider. + Model string + + // Workspace is the absolute directory the read-only file_read tool is rooted at. + // Defaults to the process working directory (override with AGENT_WORKSPACE); + // since this is the root the agent can read, set it deliberately in real use. + Workspace string + // AutoApprove bypasses the human-in-the-loop tool-approval interrupt when true. + AutoApprove bool + // MaxIterations bounds the model<->tool loop per run (env AGENT_MAX_ITERATIONS). + MaxIterations int + // CORS enables permissive CORS for local UI development. + CORS bool + // GenUIPace is the delay between step transitions on /agentic_generative_ui + // (env AGENTIC_UI_PACE_MS). A visible delay is the point of that demo; an + // instantaneous run defeats it. + GenUIPace time.Duration } -// envVar defines an environment variable handler -type envVar struct { - key string - apply func(string) error -} - -// getEnvHandlers builds handlers for environment variables -func getEnvHandlers(c *Config) []envVar { - return []envVar{ - {"AGUI_HOST", func(v string) error { c.Host = v; return nil }}, - {"AGUI_PORT", func(v string) error { - port, err := strconv.Atoi(v) - if err != nil { - return fmt.Errorf("invalid AGUI_PORT value '%s': %w", v, err) - } - c.Port = port - return nil - }}, +// Load reads configuration from the environment, applying defaults. +func Load() Config { + wd, _ := os.Getwd() + provider := envOr("MODEL_PROVIDER", "openai") + model := os.Getenv("MODEL") + if model == "" { + model = "gpt-4o" } -} - -// Default configuration values -const ( - DefaultHost = "0.0.0.0" - DefaultPort = 8000 - DefaultLogLevel = "info" - DefaultEnableSSE = true - DefaultReadTimeout = 30 * time.Second - DefaultWriteTimeout = 30 * time.Second - DefaultSSEKeepAlive = 15 * time.Second - DefaultStreamingChunkDelay = 200 * time.Millisecond -) - -// Default CORS allowed origins -var DefaultCORSAllowedOrigins = []string{"*"} - -// Valid log levels -var ValidLogLevels = map[string]slog.Level{ - "debug": slog.LevelDebug, - "info": slog.LevelInfo, - "warn": slog.LevelWarn, - "error": slog.LevelError, -} - -// New creates a new Config with default values -func New() *Config { - return &Config{ - Host: DefaultHost, - Port: DefaultPort, - LogLevel: DefaultLogLevel, - EnableSSE: DefaultEnableSSE, - ReadTimeout: DefaultReadTimeout, - WriteTimeout: DefaultWriteTimeout, - SSEKeepAlive: DefaultSSEKeepAlive, - CORSEnabled: true, - CORSAllowedOrigins: DefaultCORSAllowedOrigins, - StreamingChunkDelay: DefaultStreamingChunkDelay, + port := envInt("PORT", 8080) + if port < 1 || port > 65535 { + port = 8080 // out-of-range value would otherwise fail late at Listen with an opaque error } -} - -// LoadFromEnv loads configuration from environment variables with AGUI_ prefix -func (c *Config) LoadFromEnv() error { - for _, h := range getEnvHandlers(c) { - if val := os.Getenv(h.key); val == "" { - continue - } - if err := h.apply(os.Getenv(h.key)); err != nil { - return err - } + return Config{ + Host: envOr("HOST", "127.0.0.1"), + Port: port, + Provider: provider, + Model: model, + Workspace: envOr("AGENT_WORKSPACE", wd), + AutoApprove: envBool("AGENT_AUTO_APPROVE", false), + MaxIterations: envInt("AGENT_MAX_ITERATIONS", DefaultMaxIterations), + CORS: envBool("CORS_ENABLED", true), + GenUIPace: time.Duration(envInt("AGENTIC_UI_PACE_MS", 600)) * time.Millisecond, } - return nil } -// Validate validates the configuration values -func (c *Config) Validate() error { - var errs []error - - // Validate port range - if c.Port < 1 || c.Port > 65535 { - errs = append(errs, fmt.Errorf("port must be between 1 and 65535, got %d", c.Port)) - } - - // Validate log level - if _, ok := ValidLogLevels[c.LogLevel]; !ok { - validLevels := make([]string, 0, len(ValidLogLevels)) - for level := range ValidLogLevels { - validLevels = append(validLevels, level) - } - errs = append(errs, fmt.Errorf("invalid log level '%s', must be one of: %s", c.LogLevel, strings.Join(validLevels, ", "))) +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v } - - // Validate timeout durations are non-negative - if c.ReadTimeout < 0 { - errs = append(errs, fmt.Errorf("read timeout must be non-negative, got %v", c.ReadTimeout)) - } - - if c.WriteTimeout < 0 { - errs = append(errs, fmt.Errorf("write timeout must be non-negative, got %v", c.WriteTimeout)) - } - - if c.SSEKeepAlive < 0 { - errs = append(errs, fmt.Errorf("SSE keep-alive must be non-negative, got %v", c.SSEKeepAlive)) - } - - if c.StreamingChunkDelay < 0 { - errs = append(errs, fmt.Errorf("streaming chunk delay must be non-negative, got %v", c.StreamingChunkDelay)) - } - - if len(errs) > 0 { - return errors.Join(errs...) - } - - return nil + return def } -// LogLevel returns the slog.Level for the configured log level -func (c *Config) GetLogLevel() slog.Level { - level, ok := ValidLogLevels[c.LogLevel] - if !ok { - return slog.LevelInfo // fallback to info if invalid +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } } - return level + return def } -// LoadFromFlags loads configuration from command line flags with precedence over env vars -func (c *Config) LoadFromFlags() error { - var ( - host = flag.String("host", c.Host, "Server host address") - port = flag.Int("port", c.Port, "Server port (1-65535)") - logLevel = flag.String("log-level", c.LogLevel, "Log level (debug, info, warn, error)") - enableSSE = flag.Bool("enable-sse", c.EnableSSE, "Enable Server-Sent Events") - readTimeout = flag.Duration("read-timeout", c.ReadTimeout, "Read timeout duration") - writeTimeout = flag.Duration("write-timeout", c.WriteTimeout, "Write timeout duration") - sseKeepAlive = flag.Duration("sse-keepalive", c.SSEKeepAlive, "SSE keep-alive duration") - corsEnabled = flag.Bool("cors-enabled", c.CORSEnabled, "Enable CORS") - ) - - flag.Parse() - - // Apply flag values with precedence over env vars - c.Host = *host - c.Port = *port - c.LogLevel = strings.ToLower(*logLevel) - c.EnableSSE = *enableSSE - c.ReadTimeout = *readTimeout - c.WriteTimeout = *writeTimeout - c.SSEKeepAlive = *sseKeepAlive - c.CORSEnabled = *corsEnabled - - return nil -} - -// LoadConfig creates and loads configuration with proper precedence: flags > env > defaults -func LoadConfig() (*Config, error) { - // Start with defaults - config := New() - - // Load environment variables (override defaults) - if err := config.LoadFromEnv(); err != nil { - return nil, fmt.Errorf("failed to load environment variables: %w", err) - } - - // Load command line flags (override env vars) - if err := config.LoadFromFlags(); err != nil { - return nil, fmt.Errorf("failed to load command line flags: %w", err) - } - - // Validate the final configuration - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("configuration validation failed: %w", err) +func envBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } } - - return config, nil -} - -// LogSafeConfig logs the configuration without sensitive information -func (c *Config) LogSafeConfig(logger *slog.Logger) { - logger.Info("Server configuration loaded", - "host", c.Host, - "port", c.Port, - "log_level", c.LogLevel, - "enable_sse", c.EnableSSE, - "read_timeout", c.ReadTimeout, - "write_timeout", c.WriteTimeout, - "sse_keepalive", c.SSEKeepAlive, - "cors_enabled", c.CORSEnabled, - "streaming_chunk_delay", c.StreamingChunkDelay, - ) + return def } diff --git a/sdks/community/go/example/server/internal/document/client.go b/sdks/community/go/example/server/internal/document/client.go new file mode 100644 index 0000000000..792029317e --- /dev/null +++ b/sdks/community/go/example/server/internal/document/client.go @@ -0,0 +1,137 @@ +package document + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +const defaultModel = "gpt-4o" +const defaultSystemPrompt = "You are a helpful document analyst. Answer questions about the provided document clearly and concisely." + +type AnalyzeRequest struct { + PDFBase64 string // bare base64, no data-URL prefix + MimeType string // e.g. "application/pdf" + Prompt string // user's text question (may be empty) + Model string // overrides env default +} + +type AnalyzeResult struct { + Text string +} + +func Analyze(ctx context.Context, req AnalyzeRequest) (*AnalyzeResult, error) { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("OPENAI_API_KEY is not set") + } + model := req.Model + if model == "" { + model = os.Getenv("DOCUMENT_MODEL") + } + if model == "" { + model = defaultModel + } + prompt := req.Prompt + if prompt == "" { + prompt = "Summarize this document." + } + mimeType := req.MimeType + if mimeType == "" { + mimeType = "application/pdf" + } + + // The Responses API expects a data-URL prefix on the inline file bytes. + fileData := "data:" + mimeType + ";base64," + req.PDFBase64 + + body, err := json.Marshal(map[string]any{ + "model": model, + "input": []map[string]any{ + { + "role": "system", + "content": []map[string]any{ + {"type": "input_text", "text": defaultSystemPrompt}, + }, + }, + { + "role": "user", + "content": []map[string]any{ + { + "type": "input_file", + "filename": "document.pdf", + "file_data": fileData, + }, + { + "type": "input_text", + "text": prompt, + }, + }, + }, + }, + }) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://api.openai.com/v1/responses", + bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("Responses API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var errPayload struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&errPayload) + if errPayload.Error != nil { + return nil, fmt.Errorf("Responses API error (HTTP %d): %s", resp.StatusCode, errPayload.Error.Message) + } + return nil, fmt.Errorf("Responses API request failed with HTTP %d", resp.StatusCode) + } + + // Responses API output: output[].content[].text where type == "output_text" + var payload struct { + Output []struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decoding Responses API response: %w", err) + } + if payload.Error != nil { + return nil, fmt.Errorf("Responses API error: %s", payload.Error.Message) + } + + for _, item := range payload.Output { + for _, block := range item.Content { + if block.Type == "output_text" && block.Text != "" { + return &AnalyzeResult{Text: block.Text}, nil + } + } + } + + return nil, fmt.Errorf("Responses API returned no text output") +} diff --git a/sdks/community/go/example/server/internal/document/handler.go b/sdks/community/go/example/server/internal/document/handler.go new file mode 100644 index 0000000000..c850ea1bcb --- /dev/null +++ b/sdks/community/go/example/server/internal/document/handler.go @@ -0,0 +1,119 @@ +package document + +import ( + "bufio" + "context" + "encoding/json" + "log/slog" + "strings" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/gofiber/fiber/v3" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/agent" +) + +// Handler returns a Fiber handler for POST /document. +func Handler(shutdownCtx context.Context, logger *slog.Logger) fiber.Handler { + sw := sse.NewSSEWriter().WithLogger(logger) + return func(c fiber.Ctx) error { + var in aguitypes.RunAgentInput + if err := json.Unmarshal(c.Body(), &in); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + threadID := in.ThreadID + if threadID == "" { + threadID = aguievents.GenerateThreadID() + } + runID := in.RunID + if runID == "" { + runID = aguievents.GenerateRunID() + } + + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + + return c.SendStreamWriter(func(w *bufio.Writer) { + runCtx, cancel := context.WithCancel(shutdownCtx) + defer cancel() + emit := agent.NewEmitter(runCtx, w, sw, threadID, runID, cancel) + + defer func() { + if r := recover(); r != nil { + logger.Error("document handler panicked", "thread", threadID, "run", runID, "panic", r) + emit.RunError("the document handler crashed") + } + }() + + emit.RunStarted() + + pdfBase64, mimeType, prompt, ok := extractDocumentPart(in.Messages) + if !ok { + emit.RunError("document: no document part found in the last user message") + return + } + + result, err := Analyze(runCtx, AnalyzeRequest{ + PDFBase64: pdfBase64, + MimeType: mimeType, + Prompt: prompt, + }) + if err != nil { + logger.Error("document analysis failed", "error", err) + emit.RunError("document analysis failed: " + err.Error()) + return + } + + msgID := aguievents.GenerateMessageID() + emit.TextStart(msgID) + emit.TextContent(msgID, result.Text) + emit.TextEnd(msgID) + emit.MessagesSnapshot([]aguitypes.Message{}) + emit.RunFinishedSuccess() + }) + } +} + +// extractDocumentPart scans the last user message for a document InputContent with a +// DataSource. Also collects any text parts as the prompt. Returns ok=false if no +// document part is found or source is not inline base64 (URL-source is not supported). +func extractDocumentPart(messages []aguitypes.Message) (base64Data, mimeType, prompt string, ok bool) { + for i := len(messages) - 1; i >= 0; i-- { + m := messages[i] + if m.Role != aguitypes.RoleUser { + continue + } + parts, hasParts := m.ContentInputContents() + if !hasParts { + continue + } + var textBuf strings.Builder + for _, p := range parts { + switch p.Type { + case aguitypes.InputContentTypeDocument: + if p.Source != nil && p.Source.Type == aguitypes.InputContentSourceTypeData && p.Source.Value != "" { + base64Data = p.Source.Value + mimeType = p.Source.MimeType + if mimeType == "" { + mimeType = "application/pdf" + } + ok = true + } + case aguitypes.InputContentTypeText: + if p.Text != "" { + if textBuf.Len() > 0 { + textBuf.WriteByte('\n') + } + textBuf.WriteString(p.Text) + } + } + } + prompt = textBuf.String() + return + } + return +} diff --git a/sdks/community/go/example/server/internal/document/handler_test.go b/sdks/community/go/example/server/internal/document/handler_test.go new file mode 100644 index 0000000000..1741eea859 --- /dev/null +++ b/sdks/community/go/example/server/internal/document/handler_test.go @@ -0,0 +1,129 @@ +package document + +import ( + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" +) + +func makeUserMsg(parts []aguitypes.InputContent) aguitypes.Message { + return aguitypes.Message{Role: aguitypes.RoleUser, Content: parts} +} + +func docPart(b64, mime string) aguitypes.InputContent { + return aguitypes.InputContent{ + Type: aguitypes.InputContentTypeDocument, + Source: &aguitypes.InputContentSource{ + Type: aguitypes.InputContentSourceTypeData, + Value: b64, + MimeType: mime, + }, + } +} + +func textPart(text string) aguitypes.InputContent { + return aguitypes.InputContent{Type: aguitypes.InputContentTypeText, Text: text} +} + +func TestExtractDocumentPart(t *testing.T) { + tests := []struct { + name string + messages []aguitypes.Message + wantB64 string + wantMime string + wantPrompt string + wantOK bool + }{ + { + name: "no messages", + wantOK: false, + }, + { + name: "text-only message", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{textPart("hello")}), + }, + wantOK: false, + }, + { + name: "document part found with question", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + docPart("JVBERi0xLjQK", "application/pdf"), + textPart("What is this about?"), + }), + }, + wantB64: "JVBERi0xLjQK", + wantMime: "application/pdf", + wantPrompt: "What is this about?", + wantOK: true, + }, + { + name: "empty mimeType defaults to application/pdf", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + docPart("abc123", ""), + }), + }, + wantB64: "abc123", + wantMime: "application/pdf", + wantOK: true, + }, + { + name: "uses last user message", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{docPart("first", "application/pdf")}), + makeUserMsg([]aguitypes.InputContent{docPart("second", "application/pdf")}), + }, + wantB64: "second", + wantMime: "application/pdf", + wantOK: true, + }, + { + name: "URL-source document is skipped", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + { + Type: aguitypes.InputContentTypeDocument, + Source: &aguitypes.InputContentSource{ + Type: aguitypes.InputContentSourceTypeURL, + Value: "https://example.com/doc.pdf", + }, + }, + }), + }, + wantOK: false, + }, + { + name: "non-user message is skipped", + messages: []aguitypes.Message{ + { + Role: aguitypes.RoleAssistant, + Content: []aguitypes.InputContent{docPart("abc", "application/pdf")}, + }, + }, + wantOK: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b64, mime, prompt, ok := extractDocumentPart(tc.messages) + if ok != tc.wantOK { + t.Fatalf("ok=%v want %v", ok, tc.wantOK) + } + if !ok { + return + } + if b64 != tc.wantB64 { + t.Errorf("base64=%q want %q", b64, tc.wantB64) + } + if mime != tc.wantMime { + t.Errorf("mimeType=%q want %q", mime, tc.wantMime) + } + if prompt != tc.wantPrompt { + t.Errorf("prompt=%q want %q", prompt, tc.wantPrompt) + } + }) + } +} diff --git a/sdks/community/go/example/server/internal/document/integration_test.go b/sdks/community/go/example/server/internal/document/integration_test.go new file mode 100644 index 0000000000..8cdf1a24c1 --- /dev/null +++ b/sdks/community/go/example/server/internal/document/integration_test.go @@ -0,0 +1,49 @@ +//go:build integration + +package document + +// Run with: go test -tags integration -run TestAnalyzeIntegration ./internal/document/ +// Requires OPENAI_API_KEY in the environment. +// +// Test fixture: testdata/test_document.pdf +// A minimal hand-built PDF (588 bytes, PDF 1.4) containing the sentence: +// "This is a test document. The answer is forty-two." +// Generated for this project; no third-party copyright. + +import ( + "context" + "encoding/base64" + "os" + "strings" + "testing" +) + +func TestAnalyzeIntegration(t *testing.T) { + if os.Getenv("OPENAI_API_KEY") == "" { + t.Skip("OPENAI_API_KEY not set") + } + + raw, err := os.ReadFile("testdata/test_document.pdf") + if err != nil { + t.Fatalf("reading testdata/test_document.pdf: %v", err) + } + + result, err := Analyze(context.Background(), AnalyzeRequest{ + PDFBase64: base64.StdEncoding.EncodeToString(raw), + MimeType: "application/pdf", + Prompt: "What is the answer mentioned in this document? Reply in one sentence.", + }) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if result.Text == "" { + t.Fatal("expected non-empty response") + } + t.Logf("response: %q", result.Text) + + // The PDF contains "forty-two" — the model should mention it + lower := strings.ToLower(result.Text) + if !strings.Contains(lower, "forty-two") && !strings.Contains(lower, "42") { + t.Errorf("expected response to reference forty-two/42, got %q", result.Text) + } +} diff --git a/sdks/community/go/example/server/internal/document/testdata/test_document.pdf b/sdks/community/go/example/server/internal/document/testdata/test_document.pdf new file mode 100644 index 0000000000000000000000000000000000000000..05cd9cc515ddf21884cb310926747c03e1f8f7a4 GIT binary patch literal 588 zcmZWn%WlFj5WMFr=7Q87z;Pe}B*cLhRH?06fp3U|AsYySxU!uB{rcJok5*WgY|qZ_ z%-U{zK2Msw)d@I4sO2^o48X+ci@=_wl{>Wt`;==kh#X;x;V_^g^+bREpHTDIYn~d+ z+n&B~Ra9A6@IQ$@a-ZmBG&yRm!`>;) zA%G*%TSN&TJGyq<9dU+`vrZByrr1k>Q5*QAJVQu()@JaUS1cQ9rq1R?B=xy7~>X{D%;O YO_lNwdHw~)N^L7oohS(!jq%6i7qc0jX#fBK literal 0 HcmV?d00001 diff --git a/sdks/community/go/example/server/internal/imagegen/client.go b/sdks/community/go/example/server/internal/imagegen/client.go new file mode 100644 index 0000000000..ec10ef49ba --- /dev/null +++ b/sdks/community/go/example/server/internal/imagegen/client.go @@ -0,0 +1,113 @@ +package imagegen + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +const defaultModel = "gpt-image-1" +const defaultSize = "1024x1024" + +type GenerateRequest struct { + Prompt string + Model string // defaults to defaultModel + Size string // defaults to defaultSize +} + +type GenerateResult struct { + B64JSON string // raw base64 PNG (no data-URL prefix) + Prompt string +} + +func Generate(ctx context.Context, req GenerateRequest) (*GenerateResult, error) { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("OPENAI_API_KEY is not set") + } + model := req.Model + if model == "" { + model = os.Getenv("IMAGE_MODEL") + } + if model == "" { + model = defaultModel + } + size := req.Size + if size == "" { + size = os.Getenv("IMAGE_SIZE") + } + if size == "" { + size = defaultSize + } + + // gpt-image-1 returns base64 by default; response_format is for dall-e-2/3 only + // and may be rejected with a 400. Omitted here; the decode logic reads b64_json + // which gpt-image-1 always populates. + body, err := json.Marshal(map[string]any{ + "model": model, + "prompt": req.Prompt, + "n": 1, + "size": size, + }) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://api.openai.com/v1/images/generations", + bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("OpenAI request failed: %w", err) + } + defer resp.Body.Close() + + // Check HTTP status before decoding — a non-JSON body (gateway HTML, etc.) + // on a 429/500 would otherwise produce an opaque "decoding" error. + if resp.StatusCode >= 400 { + var errPayload struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&errPayload) + if errPayload.Error != nil { + return nil, fmt.Errorf("OpenAI error (HTTP %d): %s", resp.StatusCode, errPayload.Error.Message) + } + return nil, fmt.Errorf("OpenAI request failed with HTTP %d", resp.StatusCode) + } + + var payload struct { + Data []struct { + B64JSON string `json:"b64_json"` + } `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decoding OpenAI response: %w", err) + } + if payload.Error != nil { + return nil, fmt.Errorf("OpenAI error: %s", payload.Error.Message) + } + if len(payload.Data) == 0 || payload.Data[0].B64JSON == "" { + return nil, fmt.Errorf("OpenAI returned no image data") + } + + return &GenerateResult{ + B64JSON: payload.Data[0].B64JSON, + Prompt: req.Prompt, + }, nil +} diff --git a/sdks/community/go/example/server/internal/imagegen/handler.go b/sdks/community/go/example/server/internal/imagegen/handler.go new file mode 100644 index 0000000000..8e0b32cf7f --- /dev/null +++ b/sdks/community/go/example/server/internal/imagegen/handler.go @@ -0,0 +1,118 @@ +package imagegen + +import ( + "bufio" + "context" + "encoding/json" + "log/slog" + "strings" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/gofiber/fiber/v3" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/agent" +) + +// Handler returns a Fiber handler for POST /image-gen. +// shutdownCtx must be the server-level signal context so that SIGTERM cancels +// in-flight image generation requests (same pattern as agenticHandler). +func Handler(shutdownCtx context.Context, logger *slog.Logger) fiber.Handler { + sw := sse.NewSSEWriter().WithLogger(logger) + return func(c fiber.Ctx) error { + var in aguitypes.RunAgentInput + if err := json.Unmarshal(c.Body(), &in); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + threadID := in.ThreadID + if threadID == "" { + threadID = aguievents.GenerateThreadID() + } + runID := in.RunID + if runID == "" { + runID = aguievents.GenerateRunID() + } + + prompt := extractUserPrompt(in.Messages) + if prompt == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no user prompt provided"}) + } + + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + + return c.SendStreamWriter(func(w *bufio.Writer) { + // Parent on shutdownCtx, not c.Context(): the stream-writer runs after + // the handler returns (RequestCtx is recycled by fasthttp at that point). + runCtx, cancel := context.WithCancel(shutdownCtx) + defer cancel() + emit := agent.NewEmitter(runCtx, w, sw, threadID, runID, cancel) + + // Turn panics into RUN_ERROR rather than truncating the stream silently. + defer func() { + if r := recover(); r != nil { + logger.Error("image-gen panicked", "thread", threadID, "run", runID, "panic", r) + emit.RunError("the image-gen handler crashed") + } + }() + + emit.RunStarted() + emit.StateSnapshot(map[string]any{ + "status": "generating", + "prompt": prompt, + }) + + result, err := Generate(runCtx, GenerateRequest{Prompt: prompt}) + if err != nil { + logger.Error("image generation failed", "error", err) + emit.RunError("image generation failed: " + err.Error()) + return + } + + dataURL := "data:image/png;base64," + result.B64JSON + emit.Custom("image_generated", map[string]any{ + "prompt": prompt, + "url": dataURL, + }) + emit.StateDelta([]aguievents.JSONPatchOperation{ + {Op: "replace", Path: "/status", Value: "done"}, + }) + emit.MessagesSnapshot([]aguitypes.Message{}) + emit.RunFinishedSuccess() + }) + } +} + +// extractUserPrompt returns the text of the last user message, or "". +// Handles both plain string content and multimodal messages (joins text parts). +func extractUserPrompt(messages []aguitypes.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + m := messages[i] + if m.Role != aguitypes.RoleUser { + continue + } + if s, ok := m.ContentString(); ok { + if s = strings.TrimSpace(s); s != "" { + return s + } + } + if parts, ok := m.ContentInputContents(); ok { + var b strings.Builder + for _, p := range parts { + if p.Type == aguitypes.InputContentTypeText && p.Text != "" { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(p.Text) + } + } + if s := strings.TrimSpace(b.String()); s != "" { + return s + } + } + } + return "" +} diff --git a/sdks/community/go/example/server/internal/mcp/adapter.go b/sdks/community/go/example/server/internal/mcp/adapter.go deleted file mode 100644 index 20da42f444..0000000000 --- a/sdks/community/go/example/server/internal/mcp/adapter.go +++ /dev/null @@ -1,52 +0,0 @@ -package mcp - -import ( - "fmt" - "time" - - mcpadapter "github.com/i2y/langchaingo-mcp-adapter" - "github.com/mark3labs/mcp-go/client" - "github.com/mark3labs/mcp-go/client/transport" - langchaingoTools "github.com/tmc/langchaingo/tools" -) - -type Adapter struct { - adapter *mcpadapter.MCPAdapter - mcpClient *client.Client -} - -func NewAdapter(endpoint string) (*Adapter, error) { - httpTransport, err := getTransport(endpoint) - if err != nil { - return nil, err - } - mcpClient := client.NewClient(httpTransport) - - adapter, err := mcpadapter.New(mcpClient, mcpadapter.WithToolTimeout(30*time.Second)) - if err != nil { - return nil, fmt.Errorf("new mcp adapter: %w", err) - } - return &Adapter{ - adapter: adapter, - mcpClient: mcpClient, - }, nil -} - -func (a *Adapter) Close() error { - return a.mcpClient.Close() -} - -func (a *Adapter) Tools() ([]langchaingoTools.Tool, error) { - return a.adapter.Tools() -} - -func getTransport(endpoint string) (transport.Interface, error) { - httpTransport, err := transport.NewStreamableHTTP( - endpoint, // Replace with your MCP server URL - // You can add HTTP-specific options here like headers, OAuth, etc. - ) - if err != nil { - return nil, fmt.Errorf("create transport: %w", err) - } - return httpTransport, nil -} diff --git a/sdks/community/go/example/server/internal/mcp/server.go b/sdks/community/go/example/server/internal/mcp/server.go deleted file mode 100644 index 6130514492..0000000000 --- a/sdks/community/go/example/server/internal/mcp/server.go +++ /dev/null @@ -1,109 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -const DefaultPort = 3217 - -type Server struct { - server *server.StreamableHTTPServer - port int -} - -func NewServer(port int) (*Server, error) { - // Create a new MCP server - s := server.NewMCPServer( - "Demo 🚀", - "1.0.0", - server.WithToolCapabilities(false), - ) - - // Add tool - tool := mcp.NewTool("provide_language_options", - mcp.WithDescription("Provide a list of programming languages to choose from"), - mcp.WithString("option1", - mcp.Required(), - mcp.Description("Name of the first programming language option"), - ), - mcp.WithString("option2", - mcp.Required(), - mcp.Description("Name of the second programming language option"), - ), - mcp.WithString("option3", - mcp.Required(), - mcp.Description("Name of the third programming language option"), - ), - mcp.WithString("option4", - mcp.Required(), - mcp.Description("Name of the fourth programming language option"), - ), - ) - - // Add tool handler - s.AddTool(tool, languageChoiceHandler) - - streamableServer := server.NewStreamableHTTPServer(s) - - return &Server{ - server: streamableServer, - port: port, - }, nil -} - -func (s *Server) Start() error { - portString := fmt.Sprintf(":%d", s.port) - return s.server.Start(portString) -} - -func (s *Server) Shutdown(ctx context.Context) error { - return s.server.Shutdown(ctx) -} - -type LanguageOptions struct { - Option1 string - Option2 string - Option3 string - Option4 string -} - -func languageChoiceHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - optionOne, err := request.RequireString("option1") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - optionTwo, err := request.RequireString("option2") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - optionThree, err := request.RequireString("option3") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - optionFour, err := request.RequireString("option4") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Marshal the struct to a JSON byte slice - jsonData, err := json.Marshal(LanguageOptions{ - Option1: optionOne, - Option2: optionTwo, - Option3: optionThree, - Option4: optionFour, - }) - if err != nil { - fmt.Println("Error marshaling JSON:", err) - return nil, err - } - - return mcp.NewToolResultText(string(jsonData)), nil -} diff --git a/sdks/community/go/example/server/internal/routes/routes.go b/sdks/community/go/example/server/internal/routes/routes.go deleted file mode 100644 index 19557a376b..0000000000 --- a/sdks/community/go/example/server/internal/routes/routes.go +++ /dev/null @@ -1,131 +0,0 @@ -package routes - -import ( - "bufio" - "context" - "fmt" - "log/slog" - - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/agentic" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" - "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" - "github.com/gofiber/fiber/v3" -) - -// AgenticInput represents the input structure for the tool-based generative UI endpoint -type AgenticInput struct { - ThreadID string `json:"thread_id"` - RunID string `json:"run_id"` - State interface{} `json:"state"` - Messages []map[string]interface{} `json:"messages"` - Tools []interface{} `json:"tools"` - Context []interface{} `json:"context"` - ForwardedProps interface{} `json:"forwarded_props"` -} - -// AgenticHandler creates a Fiber handler for the tool-based generative UI route -func AgenticHandler(cfg *config.Config) fiber.Handler { - logger := slog.Default() - sseWriter := sse.NewSSEWriter().WithLogger(logger) - - return func(c fiber.Ctx) error { - // Extract request metadata - requestID := c.Locals("requestid") - if requestID == nil { - requestID = "unknown" - } - - logCtx := []any{ - "request_id", requestID, - "route", c.Route().Path, - "method", c.Method(), - } - - // Parse request body first before setting headers - var input AgenticInput - if err := c.Bind().JSON(&input); err != nil { - logger.Error("Failed to parse request body", append(logCtx, "error", err)...) - return c.Status(400).JSON(fiber.Map{ - "error": "Invalid request body", - }) - } - - // Set SSE headers after validation - c.Set("Content-Type", "text/event-stream") - c.Set("Cache-Control", "no-cache") - c.Set("Connection", "keep-alive") - c.Set("Access-Control-Allow-Origin", "*") - c.Set("Access-Control-Allow-Headers", "Cache-Control") - - logger.Info("Tool-based generative UI SSE connection established", logCtx...) - - // Get request context for cancellation - ctx := c.RequestCtx() - - // Start streaming - return c.SendStreamWriter(func(w *bufio.Writer) { - if err := streamAgenticEvents(ctx, w, sseWriter, &input, cfg, logger, logCtx); err != nil { - logger.Error("Error streaming tool-based generative UI events", append(logCtx, "error", err)...) - } - }) - } -} - -// streamAgenticEvents implements the tool-based generative UI event sequence -func streamAgenticEvents(reqCtx context.Context, w *bufio.Writer, sseWriter *sse.SSEWriter, input *AgenticInput, _ *config.Config, logger *slog.Logger, logCtx []any) error { - // Use IDs from input or generate new ones if not provided - threadID := input.ThreadID - if threadID == "" { - threadID = events.GenerateThreadID() - } - runID := input.RunID - if runID == "" { - runID = events.GenerateRunID() - } - - // Create a wrapped context for our operations - ctx := context.Background() - - // Send RUN_STARTED event - runStarted := events.NewRunStartedEvent(threadID, runID) - if err := sseWriter.WriteEvent(ctx, w, runStarted); err != nil { - return fmt.Errorf("failed to write RUN_STARTED event: %w", err) - } - - // Check for cancellation - if err := reqCtx.Err(); err != nil { - logger.Debug("Client disconnected during RUN_STARTED", append(logCtx, "reason", "context_canceled")...) - return nil - } - - // Grab last message from input, will be a user message - var lastMessage map[string]interface{} - if len(input.Messages) > 0 { - lastMessage = input.Messages[len(input.Messages)-1] - } - // grab "content" field if it exists - content, ok := lastMessage["content"].(string) - if !ok { - return fmt.Errorf("last message does not have content") - } - - err := agentic.ProcessInput(ctx, w, sseWriter, content) - if err != nil { - return fmt.Errorf("failed to process input: %w", err) - - } - - // Check for cancellation before final event - if err = reqCtx.Err(); err != nil { - return fmt.Errorf("client disconnected before RUN_FINISHED: %w", err) - } - - // Send RUN_FINISHED event - runFinished := events.NewRunFinishedEvent(threadID, runID) - if err := sseWriter.WriteEvent(ctx, w, runFinished); err != nil { - return fmt.Errorf("failed to write RUN_FINISHED event: %w", err) - } - - return nil -} diff --git a/sdks/community/go/example/server/internal/runstore/store.go b/sdks/community/go/example/server/internal/runstore/store.go new file mode 100644 index 0000000000..51fca432f0 --- /dev/null +++ b/sdks/community/go/example/server/internal/runstore/store.go @@ -0,0 +1,163 @@ +// Package runstore is an in-memory store of paused agent runs, keyed by +// threadID+runID, so an interrupted run can be resumed by a later request. +// +// This is deliberately process-local and non-durable — it exists to demonstrate +// the AG-UI interrupt -> resume cycle for the local proof, not for production. +// +// KEY-TRUST CAVEAT: keys are built from the client-supplied threadID/runID and +// are neither authenticated nor namespaced per caller. Within one process a +// client that reuses another client's IDs could overwrite or drain a paused run. +// A bounded size + TTL caps memory; real multi-tenant use must gate /agentic +// behind auth and namespace keys by the authenticated principal. +package runstore + +import ( + "sync" + "time" + + "github.com/cloudwego/eino/schema" +) + +const ( + // defaultTTL is how long a paused run survives without being resumed. + defaultTTL = 30 * time.Minute + // defaultMaxEntries caps the number of paused runs held at once. + defaultMaxEntries = 1024 +) + +// Saved is the state captured when a run pauses on a tool-approval interrupt. +type Saved struct { + // Messages is the full eino conversation up to and including the assistant + // message that proposed the pending tool calls. The assistant message is kept + // whole (including Extra, which carries reasoning items the codex model needs + // threaded across turns). + Messages []*schema.Message + // Pending are the tool calls awaiting human approval. + Pending []schema.ToolCall + // State is the agent state snapshot at pause time. + State map[string]any +} + +type entry struct { + saved *Saved + at time.Time +} + +// Store is a concurrency-safe map of paused runs with lazy TTL expiry and a +// bounded size (oldest-evicted on overflow). +type Store struct { + mu sync.Mutex + m map[string]*entry + ttl time.Duration + maxEntries int + now func() time.Time // injectable clock for tests +} + +// New creates an empty Store with default TTL and size bound. +func New() *Store { + return &Store{ + m: make(map[string]*entry), + ttl: defaultTTL, + maxEntries: defaultMaxEntries, + now: time.Now, + } +} + +// Key builds the lookup key for a run. +func Key(threadID, runID string) string { + return threadID + "|" + runID +} + +// Save records a paused run. It purges expired entries first, then evicts the +// oldest entry if the store is still at capacity. +func (s *Store) Save(key string, saved *Saved) { + s.mu.Lock() + defer s.mu.Unlock() + + now := s.now() + s.purgeExpiredLocked(now) + if _, exists := s.m[key]; !exists && len(s.m) >= s.maxEntries { + s.evictOldestLocked() + } + // Copy the caller's slices and State map so the store owns its data and a + // later mutation of the caller's Messages/Pending/State can't reach the saved + // run. (The *schema.Message pointees are still shared by reference; the only + // caller builds a fresh messages slice per turn and never mutates a message in + // place, so a deep clone of the pointees would be wasted work.) + state := make(map[string]any, len(saved.State)) + for k, v := range saved.State { + state[k] = v + } + stored := &Saved{ + Messages: append([]*schema.Message(nil), saved.Messages...), + Pending: append([]schema.ToolCall(nil), saved.Pending...), + State: state, + } + s.m[key] = &entry{saved: stored, at: now} +} + +// Load returns a paused run and whether it was present. An entry past its TTL is +// treated as a miss and deleted in-line. +func (s *Store) Load(key string) (*Saved, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.m[key] + if !ok { + return nil, false + } + if s.now().Sub(e.at) >= s.ttl { + delete(s.m, key) + return nil, false + } + return e.saved, true +} + +// Delete removes a paused run (called once it has been resumed). +func (s *Store) Delete(key string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, key) +} + +// LoadAndDelete atomically returns a paused run and removes it from the store, so +// exactly one caller can claim a given paused run. An entry past its TTL is treated +// as a miss (and is still removed). This is the resume primitive: a plain +// Load-then-Delete is a TOCTOU race that lets two concurrent resumes of the same +// thread/run both observe the entry and both execute its pending tool calls. +func (s *Store) LoadAndDelete(key string) (*Saved, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.m[key] + if !ok { + return nil, false + } + delete(s.m, key) + if s.now().Sub(e.at) >= s.ttl { + return nil, false + } + return e.saved, true +} + +func (s *Store) purgeExpiredLocked(now time.Time) { + for k, e := range s.m { + if now.Sub(e.at) >= s.ttl { + delete(s.m, k) + } + } +} + +func (s *Store) evictOldestLocked() { + var oldestKey string + var oldestAt time.Time + first := true + for k, e := range s.m { + if first || e.at.Before(oldestAt) { + oldestKey, oldestAt, first = k, e.at, false + } + } + if !first { + delete(s.m, oldestKey) + } +} diff --git a/sdks/community/go/example/server/internal/runstore/store_test.go b/sdks/community/go/example/server/internal/runstore/store_test.go new file mode 100644 index 0000000000..8f76c1062b --- /dev/null +++ b/sdks/community/go/example/server/internal/runstore/store_test.go @@ -0,0 +1,158 @@ +package runstore + +import ( + "sync" + "testing" + "time" + + "github.com/cloudwego/eino/schema" +) + +func testSaved(content string) *Saved { + return &Saved{ + Messages: []*schema.Message{schema.UserMessage(content)}, + Pending: []schema.ToolCall{{ + ID: "c1", + Function: schema.FunctionCall{Name: "file_read", Arguments: `{}`}, + }}, + State: map[string]any{"status": "awaiting_approval"}, + } +} + +func TestSaveLoad(t *testing.T) { + s := New() + s.Save("k", testSaved("hi")) + + got, ok := s.Load("k") + if !ok { + t.Fatal("expected a hit") + } + if len(got.Messages) != 1 || got.Messages[0].Content != "hi" { + t.Fatalf("unexpected saved messages: %+v", got.Messages) + } +} + +func TestLoadExpiredIsMissAndDeletes(t *testing.T) { + now := time.Unix(0, 0) + s := New() + s.now = func() time.Time { return now } + + s.Save("k", testSaved("hi")) + now = now.Add(s.ttl + time.Second) // advance past the TTL + + if _, ok := s.Load("k"); ok { + t.Fatal("expected an expired miss") + } + s.mu.Lock() + _, present := s.m["k"] + s.mu.Unlock() + if present { + t.Fatal("expired entry should be deleted in-line on Load") + } +} + +func TestLoadAndDeleteClaimsOnce(t *testing.T) { + s := New() + s.Save("k", testSaved("hi")) + + if _, ok := s.LoadAndDelete("k"); !ok { + t.Fatal("first claim should hit") + } + if _, ok := s.LoadAndDelete("k"); ok { + t.Fatal("second claim should miss (entry already claimed)") + } +} + +func TestLoadAndDeleteExpired(t *testing.T) { + now := time.Unix(0, 0) + s := New() + s.now = func() time.Time { return now } + + s.Save("k", testSaved("hi")) + now = now.Add(s.ttl + time.Second) + + if _, ok := s.LoadAndDelete("k"); ok { + t.Fatal("expired LoadAndDelete should miss") + } +} + +func TestEvictOldestOnOverflow(t *testing.T) { + now := time.Unix(0, 0) + s := New() + s.now = func() time.Time { return now } + s.maxEntries = 2 + + s.Save("a", testSaved("a")) + now = now.Add(time.Second) + s.Save("b", testSaved("b")) + now = now.Add(time.Second) + s.Save("c", testSaved("c")) // at capacity → evicts the oldest ("a") + + if _, ok := s.Load("a"); ok { + t.Error("oldest entry 'a' should have been evicted") + } + if _, ok := s.Load("b"); !ok { + t.Error("'b' should remain") + } + if _, ok := s.Load("c"); !ok { + t.Error("'c' should remain") + } +} + +func TestResaveExistingKeyDoesNotEvict(t *testing.T) { + now := time.Unix(0, 0) + s := New() + s.now = func() time.Time { return now } + s.maxEntries = 2 + + s.Save("a", testSaved("a")) + now = now.Add(time.Second) + s.Save("b", testSaved("b")) + now = now.Add(time.Second) + s.Save("a", testSaved("a2")) // re-saving an existing key must not evict 'b' + + if _, ok := s.Load("b"); !ok { + t.Error("'b' should not be evicted when re-saving existing key 'a'") + } + got, ok := s.Load("a") + if !ok || got.Messages[0].Content != "a2" { + t.Error("'a' should be updated to 'a2'") + } +} + +func TestSaveCopiesSlices(t *testing.T) { + s := New() + saved := testSaved("hi") + s.Save("k", saved) + + // Mutating the caller's slices after Save must not reach the stored run. + saved.Pending[0].Function.Name = "MUTATED" + saved.Messages[0] = schema.UserMessage("MUTATED") + + got, _ := s.Load("k") + if got.Pending[0].Function.Name != "file_read" { + t.Errorf("stored Pending aliased the caller slice: %q", got.Pending[0].Function.Name) + } + if got.Messages[0].Content != "hi" { + t.Errorf("stored Messages aliased the caller slice: %q", got.Messages[0].Content) + } +} + +func TestConcurrentAccess(t *testing.T) { + s := New() + s.maxEntries = 8 + + var wg sync.WaitGroup + for i := 0; i < 64; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + key := Key("thread", string(rune('a'+i%8))) + s.Save(key, testSaved("x")) + s.Load(key) + s.LoadAndDelete(key) + s.Delete(key) + }(i) + } + wg.Wait() +} diff --git a/sdks/community/go/example/server/internal/vision/client.go b/sdks/community/go/example/server/internal/vision/client.go new file mode 100644 index 0000000000..2bf5ff5481 --- /dev/null +++ b/sdks/community/go/example/server/internal/vision/client.go @@ -0,0 +1,123 @@ +package vision + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +const defaultModel = "gpt-4o" +const defaultSystemPrompt = "You are an expert image analyst. Describe the image clearly and answer any questions about it." + +type AnalyzeRequest struct { + ImageBase64 string // bare base64, no data-URL prefix + MimeType string // e.g. "image/png" + Prompt string // user's text question (may be empty) + Model string // overrides env default +} + +type AnalyzeResult struct { + Text string +} + +func Analyze(ctx context.Context, req AnalyzeRequest) (*AnalyzeResult, error) { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("OPENAI_API_KEY is not set") + } + model := req.Model + if model == "" { + model = os.Getenv("VISION_MODEL") + } + if model == "" { + model = defaultModel + } + prompt := req.Prompt + if prompt == "" { + prompt = "Describe this image in detail." + } + mimeType := req.MimeType + if mimeType == "" { + mimeType = "image/png" + } + + // gpt-4o expects {"type":"image_url","image_url":{"url":"data:;base64,"}} + dataURL := "data:" + mimeType + ";base64," + req.ImageBase64 + + userContent := []map[string]any{ + { + "type": "image_url", + "image_url": map[string]string{"url": dataURL}, + }, + { + "type": "text", + "text": prompt, + }, + } + + body, err := json.Marshal(map[string]any{ + "model": model, + "messages": []map[string]any{ + {"role": "system", "content": defaultSystemPrompt}, + {"role": "user", "content": userContent}, + }, + }) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://api.openai.com/v1/chat/completions", + bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("OpenAI request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var errPayload struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&errPayload) + if errPayload.Error != nil { + return nil, fmt.Errorf("OpenAI error (HTTP %d): %s", resp.StatusCode, errPayload.Error.Message) + } + return nil, fmt.Errorf("OpenAI request failed with HTTP %d", resp.StatusCode) + } + + var payload struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decoding OpenAI response: %w", err) + } + if payload.Error != nil { + return nil, fmt.Errorf("OpenAI error: %s", payload.Error.Message) + } + if len(payload.Choices) == 0 || payload.Choices[0].Message.Content == "" { + return nil, fmt.Errorf("OpenAI returned no content") + } + + return &AnalyzeResult{Text: payload.Choices[0].Message.Content}, nil +} diff --git a/sdks/community/go/example/server/internal/vision/handler.go b/sdks/community/go/example/server/internal/vision/handler.go new file mode 100644 index 0000000000..cacb33eb20 --- /dev/null +++ b/sdks/community/go/example/server/internal/vision/handler.go @@ -0,0 +1,119 @@ +package vision + +import ( + "bufio" + "context" + "encoding/json" + "log/slog" + "strings" + + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/gofiber/fiber/v3" + + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/agent" +) + +// Handler returns a Fiber handler for POST /vision. +func Handler(shutdownCtx context.Context, logger *slog.Logger) fiber.Handler { + sw := sse.NewSSEWriter().WithLogger(logger) + return func(c fiber.Ctx) error { + var in aguitypes.RunAgentInput + if err := json.Unmarshal(c.Body(), &in); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + threadID := in.ThreadID + if threadID == "" { + threadID = aguievents.GenerateThreadID() + } + runID := in.RunID + if runID == "" { + runID = aguievents.GenerateRunID() + } + + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + + return c.SendStreamWriter(func(w *bufio.Writer) { + runCtx, cancel := context.WithCancel(shutdownCtx) + defer cancel() + emit := agent.NewEmitter(runCtx, w, sw, threadID, runID, cancel) + + defer func() { + if r := recover(); r != nil { + logger.Error("vision handler panicked", "thread", threadID, "run", runID, "panic", r) + emit.RunError("the vision handler crashed") + } + }() + + emit.RunStarted() + + imageBase64, mimeType, prompt, ok := extractImagePart(in.Messages) + if !ok { + emit.RunError("vision: no image part found in the last user message") + return + } + + result, err := Analyze(runCtx, AnalyzeRequest{ + ImageBase64: imageBase64, + MimeType: mimeType, + Prompt: prompt, + }) + if err != nil { + logger.Error("vision analysis failed", "error", err) + emit.RunError("vision analysis failed: " + err.Error()) + return + } + + msgID := aguievents.GenerateMessageID() + emit.TextStart(msgID) + emit.TextContent(msgID, result.Text) + emit.TextEnd(msgID) + emit.MessagesSnapshot([]aguitypes.Message{}) + emit.RunFinishedSuccess() + }) + } +} + +// extractImagePart scans the last user message for an image InputContent with a +// DataSource. Also collects any text parts as the prompt. Returns ok=false if no +// image part is found or source is not inline base64 (URL-source is not supported). +func extractImagePart(messages []aguitypes.Message) (base64Data, mimeType, prompt string, ok bool) { + for i := len(messages) - 1; i >= 0; i-- { + m := messages[i] + if m.Role != aguitypes.RoleUser { + continue + } + parts, hasParts := m.ContentInputContents() + if !hasParts { + continue + } + var textBuf strings.Builder + for _, p := range parts { + switch p.Type { + case aguitypes.InputContentTypeImage: + if p.Source != nil && p.Source.Type == aguitypes.InputContentSourceTypeData && p.Source.Value != "" { + base64Data = p.Source.Value + mimeType = p.Source.MimeType + if mimeType == "" { + mimeType = "image/png" + } + ok = true + } + case aguitypes.InputContentTypeText: + if p.Text != "" { + if textBuf.Len() > 0 { + textBuf.WriteByte('\n') + } + textBuf.WriteString(p.Text) + } + } + } + prompt = textBuf.String() + return + } + return +} diff --git a/sdks/community/go/example/server/internal/vision/handler_test.go b/sdks/community/go/example/server/internal/vision/handler_test.go new file mode 100644 index 0000000000..0d0f37bd71 --- /dev/null +++ b/sdks/community/go/example/server/internal/vision/handler_test.go @@ -0,0 +1,119 @@ +package vision + +import ( + "testing" + + aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" +) + +func makeUserMsg(parts []aguitypes.InputContent) aguitypes.Message { + return aguitypes.Message{Role: aguitypes.RoleUser, Content: parts} +} + +func imagePart(b64, mime string) aguitypes.InputContent { + return aguitypes.InputContent{ + Type: aguitypes.InputContentTypeImage, + Source: &aguitypes.InputContentSource{ + Type: aguitypes.InputContentSourceTypeData, + Value: b64, + MimeType: mime, + }, + } +} + +func textPart(text string) aguitypes.InputContent { + return aguitypes.InputContent{Type: aguitypes.InputContentTypeText, Text: text} +} + +func TestExtractImagePart(t *testing.T) { + tests := []struct { + name string + messages []aguitypes.Message + wantB64 string + wantMime string + wantPrompt string + wantOK bool + }{ + { + name: "no messages", + wantOK: false, + }, + { + name: "text-only message", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{textPart("hello")}), + }, + wantOK: false, + }, + { + name: "image part found", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + imagePart("abc123", "image/png"), + textPart("What is this?"), + }), + }, + wantB64: "abc123", + wantMime: "image/png", + wantPrompt: "What is this?", + wantOK: true, + }, + { + name: "image with empty mimeType defaults to image/png", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + imagePart("xyz", ""), + }), + }, + wantB64: "xyz", + wantMime: "image/png", + wantOK: true, + }, + { + name: "uses last user message", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{imagePart("first", "image/jpeg")}), + makeUserMsg([]aguitypes.InputContent{imagePart("second", "image/png")}), + }, + wantB64: "second", + wantMime: "image/png", + wantOK: true, + }, + { + name: "URL-source image is skipped", + messages: []aguitypes.Message{ + makeUserMsg([]aguitypes.InputContent{ + { + Type: aguitypes.InputContentTypeImage, + Source: &aguitypes.InputContentSource{ + Type: aguitypes.InputContentSourceTypeURL, + Value: "https://example.com/img.png", + }, + }, + }), + }, + wantOK: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b64, mime, prompt, ok := extractImagePart(tc.messages) + if ok != tc.wantOK { + t.Fatalf("ok=%v want %v", ok, tc.wantOK) + } + if !ok { + return + } + if b64 != tc.wantB64 { + t.Errorf("base64=%q want %q", b64, tc.wantB64) + } + if mime != tc.wantMime { + t.Errorf("mimeType=%q want %q", mime, tc.wantMime) + } + if prompt != tc.wantPrompt { + t.Errorf("prompt=%q want %q", prompt, tc.wantPrompt) + } + }) + } +} diff --git a/sdks/community/go/example/server/internal/vision/integration_test.go b/sdks/community/go/example/server/internal/vision/integration_test.go new file mode 100644 index 0000000000..51330a4e8b --- /dev/null +++ b/sdks/community/go/example/server/internal/vision/integration_test.go @@ -0,0 +1,58 @@ +//go:build integration + +package vision + +// Run with: go test -tags integration -run TestAnalyzeIntegration ./internal/vision/ +// Requires OPENAI_API_KEY in the environment. + +import ( + "bytes" + "context" + "encoding/base64" + "image" + "image/color" + "image/png" + "os" + "strings" + "testing" +) + +func TestAnalyzeIntegration(t *testing.T) { + if os.Getenv("OPENAI_API_KEY") == "" { + t.Skip("OPENAI_API_KEY not set") + } + + result, err := Analyze(context.Background(), AnalyzeRequest{ + ImageBase64: redPNG(t), + MimeType: "image/png", + Prompt: "What color is this image? Reply with exactly one word.", + }) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if result.Text == "" { + t.Fatal("expected non-empty response") + } + t.Logf("response: %q", result.Text) + + if !strings.Contains(strings.ToLower(result.Text), "red") { + t.Errorf("expected response to mention red, got %q", result.Text) + } +} + +// redPNG returns a base64-encoded 10x10 red PNG. +func redPNG(t *testing.T) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + red := color.RGBA{R: 255, G: 0, B: 0, A: 255} + for y := range 10 { + for x := range 10 { + img.Set(x, y, red) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode test PNG: %v", err) + } + return base64.StdEncoding.EncodeToString(buf.Bytes()) +} From d05c7402f3b1bf6ca76cbdf06c4aebcdcd7d32e9 Mon Sep 17 00:00:00 2001 From: Matt Spurlin Date: Fri, 26 Jun 2026 16:51:52 -0400 Subject: [PATCH 2/3] Fix Go Dojo CORS and iteration ceiling --- sdks/community/go/example/server/cmd/main.go | 2 +- .../go/example/server/cmd/main_test.go | 20 ++++++++++++++ .../server/internal/agent/sharedstate.go | 5 ++++ .../server/internal/agent/sharedstate_test.go | 27 ++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/sdks/community/go/example/server/cmd/main.go b/sdks/community/go/example/server/cmd/main.go index 8a72e251cf..056377601a 100644 --- a/sdks/community/go/example/server/cmd/main.go +++ b/sdks/community/go/example/server/cmd/main.go @@ -110,7 +110,7 @@ func newApp(shutdownCtx context.Context, cfg config.Config, deps *agent.Deps, lo app.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Cache-Control"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Cache-Control", "X-AG-Approval"}, })) } diff --git a/sdks/community/go/example/server/cmd/main_test.go b/sdks/community/go/example/server/cmd/main_test.go index 4b50037cec..435474b31c 100644 --- a/sdks/community/go/example/server/cmd/main_test.go +++ b/sdks/community/go/example/server/cmd/main_test.go @@ -75,6 +75,26 @@ func TestAppRouteRegistrationMalformedJSON(t *testing.T) { } } +func TestAppCORSAllowsApprovalHeader(t *testing.T) { + req := newRequest(t, http.MethodOptions, "/human_in_the_loop", "") + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "X-AG-Approval") + + resp, err := testApp().Test(req) + if err != nil { + t.Fatalf("OPTIONS /human_in_the_loop: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("preflight status = %d, body = %s", resp.StatusCode, body) + } + if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(strings.ToLower(got), "x-ag-approval") { + t.Fatalf("Access-Control-Allow-Headers = %q, want X-AG-Approval", got) + } +} + func TestAppValidSSELifecycleNoCredentials(t *testing.T) { body := `{"threadId":"t","runId":"r","messages":[{"id":"u1","role":"user","content":"Plan dinner"}],"tools":[],"context":[],"forwardedProps":{},"state":{}}` resp, err := testApp().Test(newRequest(t, http.MethodPost, "/agentic_generative_ui", body)) diff --git a/sdks/community/go/example/server/internal/agent/sharedstate.go b/sdks/community/go/example/server/internal/agent/sharedstate.go index bec1732318..db80956837 100644 --- a/sdks/community/go/example/server/internal/agent/sharedstate.go +++ b/sdks/community/go/example/server/internal/agent/sharedstate.go @@ -57,6 +57,11 @@ func (s SharedState) Run(ctx context.Context, emit *Emitter, in *aguitypes.RunAg if maxIter <= 0 { maxIter = config.DefaultMaxIterations } + if maxIter > config.MaxIterationsCeiling { + s.Deps.Logger.Warn("clamping MaxIterations to ceiling", + "requested", maxIter, "ceiling", config.MaxIterationsCeiling) + maxIter = config.MaxIterationsCeiling + } for iter := 0; iter < maxIter; iter++ { if emit.Err() != nil || ctx.Err() != nil { return diff --git a/sdks/community/go/example/server/internal/agent/sharedstate_test.go b/sdks/community/go/example/server/internal/agent/sharedstate_test.go index 51beda50ab..97bbdd9965 100644 --- a/sdks/community/go/example/server/internal/agent/sharedstate_test.go +++ b/sdks/community/go/example/server/internal/agent/sharedstate_test.go @@ -13,10 +13,15 @@ import ( "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" "github.com/cloudwego/eino/schema" + "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/config" "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" ) func runSharedState(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput) string { + return runSharedStateWithMaxIterations(t, cm, in, 8) +} + +func runSharedStateWithMaxIterations(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput, maxIterations int) string { t.Helper() var buf bytes.Buffer w := bufio.NewWriter(&buf) @@ -27,7 +32,7 @@ func runSharedState(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput } deps := &Deps{ Model: cm, BaseModel: cm, Tools: tools, - Store: runstore.New(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), MaxIterations: 8, + Store: runstore.New(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), MaxIterations: maxIterations, } SharedState{Deps: deps}.Run(context.Background(), emit, in, in.ThreadID, in.RunID) _ = w.Flush() @@ -118,3 +123,23 @@ func TestSharedState_RemoveIngredient(t *testing.T) { t.Errorf("expected a remove delta:\n%s", out) } } + +func TestSharedState_MaxIterationsClampedToCeiling(t *testing.T) { + turns := make([][]*schema.Message, 70) + for i := range turns { + turns[i] = []*schema.Message{toolCallChunk("c", "apply_recipe_changes", `{"servings":4}`)} + } + m := &scriptedModel{turns: turns} + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Keep editing."}}, + State: seededRecipeState(), + } + out := runSharedStateWithMaxIterations(t, m, in, config.MaxIterationsCeiling+100) + if !strings.Contains(out, `"type":"RUN_ERROR"`) { + t.Fatalf("expected non-convergence RUN_ERROR:\n%s", out) + } + if !strings.Contains(out, "agent did not converge within 64 iterations") { + t.Fatalf("expected max-iteration ceiling in error:\n%s", out) + } +} From cce5da216ed936902e703ba4317d206832ff8eee Mon Sep 17 00:00:00 2001 From: Matt Spurlin Date: Wed, 15 Jul 2026 22:37:10 -0400 Subject: [PATCH 3/3] fix(go): preserve predictive error event ordering --- .../server/internal/agent/predictive.go | 42 +++-- .../server/internal/agent/predictive_test.go | 162 +++++++++++++++++- 2 files changed, 181 insertions(+), 23 deletions(-) diff --git a/sdks/community/go/example/server/internal/agent/predictive.go b/sdks/community/go/example/server/internal/agent/predictive.go index a8844ba115..dd6e1b0d52 100644 --- a/sdks/community/go/example/server/internal/agent/predictive.go +++ b/sdks/community/go/example/server/internal/agent/predictive.go @@ -43,10 +43,18 @@ func (p PredictiveState) Run(ctx context.Context, emit *Emitter, in *aguitypes.R messages := ensureSystemPrompt(toEinoMessages(in.Messages, p.Deps.Provider), predictiveSystemPrompt) emit.StepStarted("llm") - full, ok := p.streamPredictive(ctx, emit, doc, messages) + full, err := p.streamPredictive(ctx, emit, doc, messages) emit.StepFinished("llm") - if !ok { - return // streamPredictive already emitted RUN_ERROR or the client is gone + if err != nil { + // The actual run context and emitter state determine whether this was a + // shutdown/disconnect. A provider may return context.Canceled or + // context.DeadlineExceeded for its own internal operation while the run is + // still live; that is a genuine model failure and must reach the client. + if ctx.Err() != nil || emit.Err() != nil { + return + } + emit.RunError("the agent failed to generate a response") + return } // Settle: commit the finalized steps to the real path, then clear the ghost. @@ -89,34 +97,30 @@ func (p PredictiveState) Run(ctx context.Context, emit *Emitter, in *aguitypes.R } // streamPredictive streams the model turn, emitting the growing draft as predictive -// STATE_DELTAs under /_predictive/draft. It returns the full generated text and ok -// = true on success; on a model error it emits RUN_ERROR and returns ok = false. -func (p PredictiveState) streamPredictive(ctx context.Context, emit *Emitter, doc *DocState, messages []*schema.Message) (string, bool) { +// STATE_DELTAs under /_predictive/draft. It returns the full generated text on +// success and leaves error classification and terminal-event emission to Run, so +// Run can close the active step before emitting RUN_ERROR. +func (p PredictiveState) streamPredictive(ctx context.Context, emit *Emitter, doc *DocState, messages []*schema.Message) (string, error) { sr, err := p.Deps.BaseModel.Stream(ctx, messages) if err != nil { - if errors.Is(err, context.Canceled) || emit.Err() != nil { - return "", false - } - emit.RunError("the agent failed to generate a response") - return "", false + return "", err } defer sr.Close() var b strings.Builder for { - if ctx.Err() != nil || emit.Err() != nil { - return "", false + if err := ctx.Err(); err != nil { + return "", err + } + if err := emit.Err(); err != nil { + return "", err } chunk, recvErr := sr.Recv() if errors.Is(recvErr, io.EOF) { break } if recvErr != nil { - if errors.Is(recvErr, context.Canceled) || emit.Err() != nil { - return "", false - } - emit.RunError("the agent failed to generate a response") - return "", false + return "", recvErr } if chunk.Content == "" { continue @@ -132,7 +136,7 @@ func (p PredictiveState) streamPredictive(ctx context.Context, emit *Emitter, do emit.StateDelta(op) } } - return b.String(), true + return b.String(), nil } // stepListMarker matches a single leading ordered/unordered list marker ("1.", diff --git a/sdks/community/go/example/server/internal/agent/predictive_test.go b/sdks/community/go/example/server/internal/agent/predictive_test.go index fcacab1c57..a0f175644b 100644 --- a/sdks/community/go/example/server/internal/agent/predictive_test.go +++ b/sdks/community/go/example/server/internal/agent/predictive_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "log/slog" "strings" @@ -13,16 +14,24 @@ import ( "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" aguitypes "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/types" "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/encoding/sse" + "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" jsonpatch "github.com/evanphx/json-patch" "github.com/ag-ui-protocol/ag-ui/sdks/community/go/example/server/internal/runstore" ) -func runPredictive(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput) string { +func runPredictive(t *testing.T, cm model.ToolCallingChatModel, in *aguitypes.RunAgentInput) string { + t.Helper() + return runPredictiveWithContext(t, context.Background(), cm, in) +} + +func runPredictiveWithContext(t *testing.T, runCtx context.Context, cm model.ToolCallingChatModel, in *aguitypes.RunAgentInput) string { t.Helper() var buf bytes.Buffer w := bufio.NewWriter(&buf) + // Keep the encoder context live so cancellation tests can observe whether Run + // itself attempts a terminal event after the separate run context is canceled. emit := NewEmitter(context.Background(), w, sse.NewSSEWriter(), in.ThreadID, in.RunID, nil) tools, err := NewReadOnlyToolset(t.TempDir()) if err != nil { @@ -32,11 +41,42 @@ func runPredictive(t *testing.T, cm *scriptedModel, in *aguitypes.RunAgentInput) Model: cm, BaseModel: cm, Tools: tools, Store: runstore.New(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), MaxIterations: 8, } - PredictiveState{Deps: deps}.Run(context.Background(), emit, in, in.ThreadID, in.RunID) + PredictiveState{Deps: deps}.Run(runCtx, emit, in, in.ThreadID, in.RunID) _ = w.Flush() return buf.String() } +type predictiveFailureModel struct { + startErr error + chunks []*schema.Message + recvErr error +} + +func (m predictiveFailureModel) Generate(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error) { + return nil, errors.New("unused") +} + +func (m predictiveFailureModel) WithTools([]*schema.ToolInfo) (model.ToolCallingChatModel, error) { + return m, nil +} + +func (m predictiveFailureModel) Stream(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error) { + if m.startErr != nil { + return nil, m.startErr + } + sr, sw := schema.Pipe[*schema.Message](len(m.chunks) + 2) + go func() { + defer sw.Close() + for _, chunk := range m.chunks { + sw.Send(chunk, nil) + } + if m.recvErr != nil { + sw.Send(nil, m.recvErr) + } + }() + return sr, nil +} + func TestPredictiveState_PredictThenCommit(t *testing.T) { // The steps generation streams over several text chunks. m := &scriptedModel{turns: [][]*schema.Message{{ @@ -67,6 +107,119 @@ func TestPredictiveState_PredictThenCommit(t *testing.T) { } } +func TestPredictiveState_ModelFailureEndsAfterClosingStep(t *testing.T) { + tests := []struct { + name string + model model.ToolCallingChatModel + wantPredictiveDelta bool + }{ + { + name: "stream start", + model: predictiveFailureModel{startErr: errors.New("provider rejected request")}, + }, + { + name: "provider canceled while run active", + model: predictiveFailureModel{startErr: context.Canceled}, + }, + { + name: "provider deadline while run active", + model: predictiveFailureModel{startErr: context.DeadlineExceeded}, + }, + { + name: "mid stream", + model: predictiveFailureModel{ + chunks: []*schema.Message{textChunk("Boil the pasta.\n")}, + recvErr: errors.New("provider stream dropped"), + }, + wantPredictiveDelta: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := &aguitypes.RunAgentInput{ + ThreadID: "t", RunID: "r", + Messages: []aguitypes.Message{{ID: "u1", Role: aguitypes.RoleUser, Content: "Rewrite the steps."}}, + State: seededRecipeState(), + } + out := runPredictive(t, tt.model, in) + evs := sseData(t, out) + if len(evs) < 2 { + t.Fatalf("expected terminal STEP_FINISHED and RUN_ERROR events:\n%s", out) + } + + stepFinished, runError := evs[len(evs)-2], evs[len(evs)-1] + if stepFinished["type"] != "STEP_FINISHED" || stepFinished["stepName"] != "llm" { + t.Errorf("penultimate event = %#v, want STEP_FINISHED(llm)", stepFinished) + } + if runError["type"] != "RUN_ERROR" { + t.Errorf("final event = %#v, want RUN_ERROR", runError) + } + + runErrors := 0 + predictiveDeltas := 0 + for _, ev := range evs { + switch ev["type"] { + case "RUN_ERROR": + runErrors++ + case "RUN_FINISHED", "MESSAGES_SNAPSHOT": + t.Errorf("failure path must not continue into settlement; got %#v", ev) + case "STATE_DELTA": + raw, _ := json.Marshal(ev["delta"]) + if strings.Contains(string(raw), predictiveDraftPath) { + predictiveDeltas++ + } + if strings.Contains(string(raw), "/recipe/steps") { + t.Errorf("failure path must not commit recipe steps; got %#v", ev) + } + } + } + if runErrors != 1 { + t.Errorf("RUN_ERROR count = %d, want 1:\n%s", runErrors, out) + } + if tt.wantPredictiveDelta && predictiveDeltas == 0 { + t.Errorf("mid-stream failure must occur after at least one predictive delta:\n%s", out) + } + }) + } +} + +func TestPredictiveState_CancellationDoesNotEmitTerminalEvent(t *testing.T) { + tests := []struct { + name string + newContext func() (context.Context, context.CancelFunc) + }{ + { + name: "canceled run context", + newContext: func() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx, cancel + }, + }, + { + name: "expired run deadline", + newContext: func() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 0) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := tt.newContext() + defer cancel() + in := &aguitypes.RunAgentInput{ThreadID: "t", RunID: "r", State: seededRecipeState()} + out := runPredictiveWithContext(t, ctx, predictiveFailureModel{startErr: ctx.Err()}, in) + for _, ev := range sseData(t, out) { + if ev["type"] == "RUN_ERROR" || ev["type"] == "RUN_FINISHED" { + t.Errorf("canceled run must not emit a terminal run event; got %#v", ev) + } + } + }) + } +} + // TestPredictiveState_DropPredictionsInvariant proves that a client which ignores // every /_predictive delta still reaches the correct committed state: applying only // the committed (non-/_predictive) deltas to the initial snapshot yields the final @@ -125,9 +278,10 @@ func sseData(t *testing.T, out string) []map[string]any { continue } var ev map[string]any - if json.Unmarshal([]byte(rest), &ev) == nil { - evs = append(evs, ev) + if err := json.Unmarshal([]byte(rest), &ev); err != nil { + t.Fatalf("decode SSE data line %q: %v", line, err) } + evs = append(evs, ev) } return evs }