diff --git a/docs/guild-cli.md b/docs/guild-cli.md new file mode 100644 index 0000000..c1b4944 --- /dev/null +++ b/docs/guild-cli.md @@ -0,0 +1,256 @@ +# Forge Guild CLI + +A command-line interface for running and debugging Forge guilds locally without +the rustic-ui frontend. + +## Features + +- Launch guilds from JSON/YAML specs +- Interactive REPL for chatting with guilds +- Real-time message flow visualization +- Agent status monitoring +- Routing decision display +- Ctrl+C signal handling for clean shutdown +- Auto-detection of Python 3.13+ +- Quiet mode to hide noisy logs +- Guild spec validation and inspection + +## Prerequisites + +- Python 3.13+ (auto-detected from pyenv or system) +- Go 1.25+ (for building; see `forge-go/go.mod`) +- Redis or NATS backend + +## Installation + +From the `forge-go` directory: + +```bash +go build -o forge ./cmd/forge +# Optionally install to PATH +sudo cp forge /usr/local/bin/ +``` + +## Quick Start + +```bash +# Run a guild (quiet mode) +./forge guild run -q ../guilds/echo_app.json + +# Type messages to interact with the guild +> hello! + +# Use commands +> /status +> /help +> /quit +``` + +## Usage + +### Run a Guild + +```bash +# Basic usage +./forge guild run ../guilds/echo_app.json + +# With a specific Python interpreter +./forge guild run --python /path/to/python3.13 ../guilds/echo_app.json + +# Quiet mode (minimal startup output, clean display) +./forge guild run -q ../guilds/echo_app.json + +# Verbose mode (show all message details) +./forge guild run -v ../guilds/echo_app.json + +# Show routing information +./forge guild run --show-routing ../guilds/echo_app.json +``` + +### Inspect a Guild Spec + +```bash +./forge guild inspect ../guilds/echo_app.json +``` + +Shows guild structure, agents, routing rules, and dependencies. + +### Validate a Guild Spec + +```bash +./forge guild validate ../guilds/echo_app.json +``` + +Checks for syntax errors and validates configuration. + +## Interactive Commands + +Once the REPL starts, you can use: + +- Type any text to send a chat message to the guild +- `/status` - Show current agent status +- `/help` - Show help message +- `/quit` or `/exit` - Exit the REPL +- `Ctrl+C` - Shutdown cleanly + +## Flags + +### Common Flags + +- `--backend` - Messaging backend: `redis` or `nats` (default: `nats`) +- `--org-id` - Organization ID (default: `local-dev`) +- `--user-id` - User ID for sending messages (default: `test-user`) +- `--user-name` - User display name (default: `Test User`) +- `--supervisor` - Supervisor type: `process`, `docker`, or `bubblewrap` (default: `process`) +- `--python` - Python executable path (auto-detected if not specified) + +### Output Control + +- `-q, --quiet` - Minimal startup output, hide noisy logs (recommended) +- `-v, --verbose` - Show full message details including payloads +- `--show-routing` - Show routing history and transformations (default: `true`) + +## Message Display + +The CLI automatically filters noisy internal messages: + +**Shown by default:** +- User chat messages +- Agent responses +- Errors and warnings +- Important state changes + +**Hidden by default** (use `-v` to see): +- Health checks and heartbeats +- Internal state updates +- Infrastructure events +- HTTP request logs + +## Python Version + +The CLI requires **Python 3.13+**. It auto-detects in this order: + +1. `pyenv which python` (preferred - gets the real path, not the shim) +2. `python` from `PATH` +3. `python3` from `PATH` + +### Setting up Python 3.13 + +If you hit a Python version error, recreate your virtual environment with +Python 3.13. From the repository root: + +```bash +# Remove the old venv +rm -rf .env + +# Create a new venv with Python 3.13 +python3.13 -m venv .env +# ...or, if pyenv already resolves to 3.13: +python -m venv .env + +# Activate and install +source .env/bin/activate +pip install -e ./forge-python +``` + +## Troubleshooting + +### "Python 3.12 does not satisfy Python>=3.13" + +Your virtual environment was created with an older Python. See "Setting up +Python 3.13" above. + +### "could not find forge root" + +Run the CLI from the `forge-go` directory or any subdirectory of the forge +repository. + +### Server logs cluttering output + +Use the `-q` flag for quiet mode. Server logs are redirected to a per-run temp +directory (`/forge-cli-*/server.log`). + +### Guild won't launch + +1. Check the Python version reported at startup (should be 3.13+). +2. Use `/status` to check whether agents are running. +3. Look at the server log written under `/forge-cli-*/server.log`. +4. Confirm the agent registry was seeded: look for "Seeding agent registry" at + startup. + +## Examples + +### Echo Guild (recommended for testing) + +```bash +./forge guild run -q ../guilds/echo_app.json +``` + +Type messages and see them echoed back by the agent. + +### Custom Configuration + +```bash +./forge guild run \ + --backend redis \ + --org-id my-org \ + --user-id alice \ + --user-name "Alice Smith" \ + --python "$(pyenv which python)" \ + -q \ + ../guilds/echo_app.json +``` + +## Development + +### Project Structure + +``` +forge-go/ +├── cli/ +│ ├── guild_runtime.go # Embedded runtime + guild lifecycle +│ ├── subscription.go # Message subscriptions +│ └── message_builder.go # Message construction +├── command/ +│ ├── guild.go # Command group +│ ├── guild_run.go # Interactive REPL +│ ├── guild_inspect.go # Guild inspection +│ └── guild_validate.go # Guild validation +└── go.mod # Dependencies +``` + +### Architecture + +``` +┌─────────────────────────────────────────┐ +│ CLI REPL (guild_run.go) │ +│ - User input handling │ +│ - Message display │ +│ - Command processing │ +└─────────────────┬────────────────────────┘ + │ +┌─────────────────▼────────────────────────┐ +│ GuildRuntime (guild_runtime.go) │ +│ - Embedded forge server │ +│ - Agent registry seeding │ +│ - Guild lifecycle management │ +└─────────────────┬────────────────────────┘ + │ +┌─────────────────▼────────────────────────┐ +│ Forge Server (embedded) │ +│ - Redis/NATS messaging │ +│ - Agent supervision │ +│ - Guild management API │ +└─────────────────┬────────────────────────┘ + │ +┌─────────────────▼────────────────────────┐ +│ Python Agents (Python 3.13+) │ +│ - Guild manager agent │ +│ - User-defined agents │ +│ - Message processing │ +└──────────────────────────────────────────┘ +``` + +## License + +Same as the Forge project. diff --git a/forge-go/api/catalog.go b/forge-go/api/catalog.go index 6466416..b771024 100644 --- a/forge-go/api/catalog.go +++ b/forge-go/api/catalog.go @@ -1395,6 +1395,15 @@ func handleLaunchGuildFromBlueprint(s store.Store, pusher protocol.ControlPusher ReplyError(w, http.StatusUnprocessableEntity, "invalid guild spec") return } + // Resolve mustache {{ }} placeholders from the (merged) configuration bag, + // mirroring the Python API server's GuildBuilder._from_spec_dict(...) at + // launch. Without this, placeholders leak into the launched guild spec. + rendered, err := guild.RenderConfiguration(&guildSpec) + if err != nil { + ReplyError(w, http.StatusUnprocessableEntity, "invalid guild spec: "+err.Error()) + return + } + guildSpec = *rendered if req.GuildID != nil { guildSpec.ID = *req.GuildID } diff --git a/forge-go/api/catalog_templating_test.go b/forge-go/api/catalog_templating_test.go new file mode 100644 index 0000000..2a5e05e --- /dev/null +++ b/forge-go/api/catalog_templating_test.go @@ -0,0 +1,274 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rustic-ai/forge/forge-go/guild/store" + "github.com/rustic-ai/forge/forge-go/protocol" +) + +// varBlueprintSpec mirrors the rustic-ai Python API test +// (api/tests/catalog/test_blueprint_with_vars.py): a guild spec carrying a +// configuration bag + schema and {{ }} mustache placeholders in the agent +// name/properties and in a routing step. Launching it must substitute the +// configuration values into those placeholders. +func varBlueprintSpec() store.JSONB { + return store.JSONB{ + "name": "vars_guild", + "description": "guild with templated agents", + "configuration_schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "agent1": map[string]any{"type": "string"}, + "model_id": map[string]any{"type": "string"}, + "model_version": map[string]any{"type": "number"}, + }, + }, + "configuration": map[string]any{ + "agent1": "asdf", + "model_id": "custom/model_1", + "model_version": 2, + }, + "agents": []any{ + map[string]any{ + "name": "{{ agent1 }}", + "description": "A simple agent with variable properties", + "class_name": "test.agents.SimpleAgentWithProps", + "properties": map[string]any{ + "prop1": "{{ model_id }}", + "prop2": "{{ model_version }}", + }, + }, + }, + "routes": map[string]any{ + "steps": []any{ + map[string]any{ + "agent": map[string]any{"name": "{{ agent1 }}"}, + "origin_filter": map[string]any{"origin_message_format": "__main__.FilteringMessage"}, + }, + }, + }, + } +} + +// TestLaunchBlueprint_RendersConfiguration replicates the Python blueprint-vars +// test and expands it to the aspects that test never asserted: it reads the +// launched guild back and checks that the {{ }} placeholders were actually +// rendered in the agent name, agent properties, AND the routing step (the +// Python test only checked HTTP status). It also covers a per-launch config +// override and rejection of an ill-typed configuration value. +func TestLaunchBlueprint_RendersConfiguration(t *testing.T) { + db, err := store.NewGormStore("sqlite", "file::memory:") + if err != nil { + t.Fatalf("init db: %v", err) + } + + // Blueprint create validation requires the agent class to exist in the catalog. + if err := db.RegisterAgent(&store.CatalogAgentEntry{ + QualifiedClassName: "test.agents.SimpleAgentWithProps", + AgentName: "SimpleAgentWithProps", + AgentDoc: ptrString("simple agent with props"), + AgentPropsSchema: store.JSONB{"type": "object"}, + MessageHandlers: store.JSONB{}, + }); err != nil { + t.Fatalf("register agent: %v", err) + } + + mux := http.NewServeMux() + RegisterCatalogRoutes(mux, db) + + // --- create the blueprint via the HTTP endpoint --- + createBody, _ := json.Marshal(BlueprintCreateRequest{ + Name: "vars bp", + Description: "templated", + Exposure: store.ExposurePublic, + AuthorID: "author-1", + Spec: varBlueprintSpec(), + }) + req, _ := http.NewRequest("POST", "/catalog/blueprints", bytes.NewBuffer(createBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("create blueprint: want 201, got %d: %s", rr.Code, rr.Body.String()) + } + var created struct { + ID string `json:"id"` + } + if err := json.NewDecoder(rr.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + + // The blueprint is a template: it must be stored RAW, placeholders intact. + storedBP, err := db.GetBlueprint(created.ID) + if err != nil { + t.Fatalf("get blueprint: %v", err) + } + if raw, _ := json.Marshal(storedBP.Spec); !strings.Contains(string(raw), "{{ agent1 }}") { + t.Errorf("stored blueprint should keep raw placeholder {{ agent1 }}, got: %s", raw) + } + + // launch performs a blueprint launch with the given per-request configuration + // override and returns the launched guild spec read back from the store. + launch := func(t *testing.T, cfg map[string]any) *protocol.GuildSpec { + t.Helper() + body, _ := json.Marshal(LaunchGuildFromBlueprintRequest{ + GuildName: "Launched Guild", + UserID: "user-1", + OrgID: "org-1", + Configuration: cfg, + }) + lreq, _ := http.NewRequest("POST", "/catalog/blueprints/"+created.ID+"/guilds", bytes.NewBuffer(body)) + lreq.Header.Set("Content-Type", "application/json") + lrr := httptest.NewRecorder() + mux.ServeHTTP(lrr, lreq) + if lrr.Code != http.StatusCreated { + t.Fatalf("launch: want 201, got %d: %s", lrr.Code, lrr.Body.String()) + } + var launched struct { + ID string `json:"id"` + } + if err := json.NewDecoder(lrr.Body).Decode(&launched); err != nil { + t.Fatalf("decode launch response: %v", err) + } + gm, err := db.GetGuild(launched.ID) + if err != nil { + t.Fatalf("get launched guild: %v", err) + } + return store.ToGuildSpec(gm) + } + + routeAgentName := func(t *testing.T, gs *protocol.GuildSpec) string { + t.Helper() + if gs.Routes == nil || len(gs.Routes.Steps) != 1 { + t.Fatalf("want exactly one routing step, got %+v", gs.Routes) + } + ag := gs.Routes.Steps[0].Agent + if ag == nil || ag.Name == nil { + t.Fatalf("routing step is missing an agent name: %+v", gs.Routes.Steps[0]) + } + return *ag.Name + } + + t.Run("default configuration is rendered", func(t *testing.T) { + gs := launch(t, nil) + if len(gs.Agents) != 1 { + t.Fatalf("want one agent, got %d", len(gs.Agents)) + } + a := gs.Agents[0] + if a.Name != "asdf" { + t.Errorf("agent name = %q, want rendered %q (blueprint configuration not applied at launch)", a.Name, "asdf") + } + if a.Properties["prop1"] != "custom/model_1" { + t.Errorf("prop1 = %v, want rendered %q", a.Properties["prop1"], "custom/model_1") + } + if s := fmt.Sprint(a.Properties["prop2"]); strings.Contains(s, "{{") { + t.Errorf("prop2 = %q, want rendered value (placeholder still present)", s) + } + if got := routeAgentName(t, gs); got != "asdf" { + t.Errorf("routing step agent name = %q, want rendered %q", got, "asdf") + } + }) + + t.Run("per-launch override is rendered", func(t *testing.T) { + gs := launch(t, map[string]any{ + "agent1": "007", + "model_id": "custom/secret_model", + "model_version": 3, + }) + a := gs.Agents[0] + if a.Name != "007" { + t.Errorf("agent name = %q, want rendered %q", a.Name, "007") + } + if a.Properties["prop1"] != "custom/secret_model" { + t.Errorf("prop1 = %v, want rendered %q", a.Properties["prop1"], "custom/secret_model") + } + if got := routeAgentName(t, gs); got != "007" { + t.Errorf("routing step agent name = %q, want rendered %q", got, "007") + } + }) + + t.Run("ill-typed configuration override is rejected", func(t *testing.T) { + body, _ := json.Marshal(LaunchGuildFromBlueprintRequest{ + GuildName: "Launched Guild", + UserID: "user-1", + OrgID: "org-1", + Configuration: map[string]any{"agent1": 7}, // schema says string + }) + lreq, _ := http.NewRequest("POST", "/catalog/blueprints/"+created.ID+"/guilds", bytes.NewBuffer(body)) + lreq.Header.Set("Content-Type", "application/json") + lrr := httptest.NewRecorder() + mux.ServeHTTP(lrr, lreq) + if lrr.Code != http.StatusUnprocessableEntity { + t.Errorf("ill-typed config: want 422, got %d: %s", lrr.Code, lrr.Body.String()) + } + }) +} + +// TestLaunchBlueprint_ConfigValueHTMLEscaping documents a bug shared by the Go +// (cbroglie/mustache) and Python (chevron) renderers: because substitution runs +// over a JSON-serialized spec with default mustache HTML-escaping, a config +// value containing & < > is corrupted (e.g. "R&D" -> "R&D"). Skipped until +// the escaping is fixed in BOTH renderers (fixing only one breaks Go/Python +// render parity). See guild.resolveTemplates and core builders._from_spec_dict. +func TestLaunchBlueprint_ConfigValueHTMLEscaping(t *testing.T) { + t.Skip("known shared bug: mustache/chevron HTML-escape config values (& < >); must be fixed in both renderers for parity") + + db, err := store.NewGormStore("sqlite", "file::memory:") + if err != nil { + t.Fatalf("init db: %v", err) + } + if err := db.RegisterAgent(&store.CatalogAgentEntry{ + QualifiedClassName: "test.agents.SimpleAgentWithProps", + AgentName: "SimpleAgentWithProps", + AgentPropsSchema: store.JSONB{"type": "object"}, + MessageHandlers: store.JSONB{}, + }); err != nil { + t.Fatalf("register agent: %v", err) + } + mux := http.NewServeMux() + RegisterCatalogRoutes(mux, db) + + createBody, _ := json.Marshal(BlueprintCreateRequest{ + Name: "vars bp", + Exposure: store.ExposurePublic, + AuthorID: "author-1", + Spec: varBlueprintSpec(), + }) + req, _ := http.NewRequest("POST", "/catalog/blueprints", bytes.NewBuffer(createBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + var created struct { + ID string `json:"id"` + } + _ = json.NewDecoder(rr.Body).Decode(&created) + + body, _ := json.Marshal(LaunchGuildFromBlueprintRequest{ + GuildName: "Launched Guild", + UserID: "user-1", + OrgID: "org-1", + Configuration: map[string]any{"agent1": "R&D ", "model_id": "m", "model_version": 1}, + }) + lreq, _ := http.NewRequest("POST", "/catalog/blueprints/"+created.ID+"/guilds", bytes.NewBuffer(body)) + lreq.Header.Set("Content-Type", "application/json") + lrr := httptest.NewRecorder() + mux.ServeHTTP(lrr, lreq) + var launched struct { + ID string `json:"id"` + } + _ = json.NewDecoder(lrr.Body).Decode(&launched) + gm, _ := db.GetGuild(launched.ID) + gs := store.ToGuildSpec(gm) + + // The value must survive verbatim, NOT HTML-escaped to "R&D <team>". + if gs.Agents[0].Name != "R&D " { + t.Errorf("agent name = %q, want verbatim %q (HTML-escaping corruption)", gs.Agents[0].Name, "R&D ") + } +} diff --git a/forge-go/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go new file mode 100644 index 0000000..e513e99 --- /dev/null +++ b/forge-go/cli/guild_runtime.go @@ -0,0 +1,810 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/nats-io/nats.go" + "github.com/redis/go-redis/v9" + "github.com/rustic-ai/forge/forge-go/guild" + "github.com/rustic-ai/forge/forge-go/guild/store" + "github.com/rustic-ai/forge/forge-go/messaging" + "github.com/rustic-ai/forge/forge-go/protocol" + "gopkg.in/yaml.v3" +) + +// agentStatusKVBucket is the JetStream KV bucket the supervisor writes agent +// runtime status into when the server runs with the NATS backend. Mirrors the +// constant in the supervisor package (kept in sync manually to avoid importing +// the supervisor package from the CLI). +const agentStatusKVBucket = "agent-status" + +// RuntimeConfig holds configuration for the guild runtime +type RuntimeConfig struct { + Backend string // "redis" or "nats" + OrgID string // Organization ID + UserID string // User ID for sending messages + UserName string // User name for sending messages + ForgeHome string // Forge home directory (optional) + ForgeRoot string // Forge repository root + DependencyConfig string // Path to dependency config + AgentRegistry string // Path to agent registry + ForgePythonPath string // Path to forge-python package + NATSUrl string // NATS server URL (if using NATS backend) + SupervisorType string // "process", "docker", or "bubblewrap" + PythonPath string // Path to Python executable (optional, will auto-detect) + UVPython string // Interpreter to pin uv/uvx to for agents (e.g. "3.13"); empty lets uv choose +} + +// GuildRuntime manages an embedded forge runtime for running guilds +type GuildRuntime struct { + config RuntimeConfig + serverCmd *exec.Cmd + serverBase string + rusticBase string + redisAddr string + redisClient *redis.Client + natsURL string + natsConn *nats.Conn + msgBackend messaging.Backend // Cached messaging backend (Redis or NATS) + tempDir string + dbPath string + dataDir string + ctx context.Context + cancel context.CancelFunc + agentNames map[string]string // Maps agent ID to agent name +} + +// AgentStatus represents the status of an agent +type AgentStatus struct { + AgentID string + State string + PID int +} + +// NewGuildRuntime creates a new guild runtime instance +func NewGuildRuntime(config RuntimeConfig) (*GuildRuntime, error) { + // Set defaults + if config.Backend == "" { + config.Backend = "nats" + } + if config.OrgID == "" { + config.OrgID = "local-dev" + } + if config.UserID == "" { + config.UserID = "test-user" + } + if config.UserName == "" { + config.UserName = "Test User" + } + if config.SupervisorType == "" { + config.SupervisorType = "process" + } + + // Create temp directory for runtime + tempDir, err := os.MkdirTemp("", "forge-cli-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + + dbPath := filepath.Join(tempDir, "forge-cli.db") + dataDir := filepath.Join(tempDir, "forge-data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + os.RemoveAll(tempDir) + return nil, fmt.Errorf("failed to create data dir: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + runtime := &GuildRuntime{ + config: config, + tempDir: tempDir, + dbPath: dbPath, + dataDir: dataDir, + ctx: ctx, + cancel: cancel, + agentNames: make(map[string]string), + } + + return runtime, nil +} + +// Start starts the embedded forge server +func (r *GuildRuntime) Start() error { + // Reserve a local address for the server + listenAddr, err := reserveLocalAddr() + if err != nil { + return fmt.Errorf("failed to reserve listen address: %w", err) + } + + backend := strings.ToLower(strings.TrimSpace(r.config.Backend)) + if backend == "" { + backend = "nats" + } + + // Reserve an address for the embedded message broker the server will boot + // (Redis or NATS, mutually exclusive). The CLI later connects its own client + // to whichever broker was chosen. + brokerAddr, err := reserveLocalAddr() + if err != nil { + return fmt.Errorf("failed to reserve broker address: %w", err) + } + if backend != "nats" { + r.redisAddr = brokerAddr + } + + // Find forge binary + binPath, err := exec.LookPath("forge") + if err != nil { + // Try to build it + slog.Info("forge binary not found, attempting to build...") + buildCmd := exec.Command("go", "build", "-o", filepath.Join(r.tempDir, "forge"), "./cmd/forge") + buildCmd.Dir = r.config.ForgeRoot + if output, err := buildCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to build forge: %w\n%s", err, output) + } + binPath = filepath.Join(r.tempDir, "forge") + } + + // Build server arguments + args := []string{ + "server", + "--listen", listenAddr, + "--db", "sqlite:///" + r.dbPath, + "--backend", backend, + "--data-dir", r.dataDir, + "--dependency-config", r.config.DependencyConfig, + "--with-client", + "--client-node-id", "cli-node", + "--client-metrics-addr", "127.0.0.1:0", + "--client-default-supervisor", r.config.SupervisorType, + } + + if backend == "nats" { + if r.config.NATSUrl != "" { + // Caller provided an external NATS server; point forge at it and + // connect the CLI there too. + r.natsURL = r.config.NATSUrl + args = append(args, "--nats", r.config.NATSUrl) + } else { + // Boot the server's embedded NATS at the address we reserved so the + // CLI can connect its own client to it. Matches how rustic-ui + // launches forge (--backend nats with an embedded server). + r.natsURL = "nats://" + brokerAddr + args = append(args, "--embedded-nats-addr", brokerAddr) + } + } else { + args = append(args, "--embedded-redis-addr", brokerAddr) + } + + // Pin the interpreter uv/uvx uses for Python agents, if requested. + if r.config.UVPython != "" { + args = append(args, "--uv-python", r.config.UVPython) + } + + // Create server command + cmd := exec.Command(binPath, args...) + cmd.Dir = r.config.ForgeRoot + // Put the server in its own process group so teardown can signal the whole + // tree at once. Platform-specific; a no-op on systems without process groups. + setProcessGroup(cmd) + + // Redirect server logs to temp files to avoid cluttering the CLI + // Only show them if there's an error + logFile := filepath.Join(r.tempDir, "server.log") + if logF, err := os.Create(logFile); err == nil { + cmd.Stdout = logF + cmd.Stderr = logF + } + + // Set environment variables + env := []string{} + + // If Python path is specified, ensure its directory is first in PATH + if r.config.PythonPath != "" { + pythonDir := filepath.Dir(r.config.PythonPath) + currentPath := os.Getenv("PATH") + env = append(env, "PATH="+pythonDir+":"+currentPath) + env = append(env, "FORGE_PYTHON_EXECUTABLE="+r.config.PythonPath) + } + + // Add all other environment variables + for _, e := range os.Environ() { + // Skip PATH if we already set it above + if r.config.PythonPath != "" && strings.HasPrefix(e, "PATH=") { + continue + } + env = append(env, e) + } + + // Add Forge-specific variables + env = append(env, + "FORGE_AGENT_REGISTRY="+r.config.AgentRegistry, + "FORGE_PYTHON_PKG="+r.config.ForgePythonPath, + "FORGE_ENABLE_PUBLIC_API=true", + "FORGE_ENABLE_UI_API=true", + "FORGE_IDENTITY_MODE=local", + "FORGE_QUOTA_MODE=local", + "PYTHONUNBUFFERED=1", + ) + if backend == "nats" { + // Python agents need the NATS messaging plugin when the data plane is NATS. + env = append(env, "FORGE_EXTRA_DEPS=rusticai-nats") + // Match rustic-ui's NATS message TTL (30 days) so local CLI runs behave + // the same as the desktop app. + natsTTL := os.Getenv("RUSTIC_AI_NATS_MSG_TTL") + if natsTTL == "" { + natsTTL = "2592000" + } + env = append(env, "RUSTIC_AI_NATS_MSG_TTL="+natsTTL) + } + + cmd.Env = env + + // Start server + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start forge server: %w", err) + } + + r.serverCmd = cmd + r.serverBase = "http://" + listenAddr + r.rusticBase = r.serverBase + "/rustic" + + // Wait for server to be ready + if err := r.waitForReady(30 * time.Second); err != nil { + r.kill() + return fmt.Errorf("server did not become ready: %w", err) + } + + // Connect the CLI's own client to whichever broker the server booted. + switch backend { + case "nats": + nc, err := nats.Connect(r.natsURL, + nats.RetryOnFailedConnect(true), + nats.MaxReconnects(-1), + nats.ReconnectWait(200*time.Millisecond)) + if err != nil { + r.kill() + return fmt.Errorf("failed to connect to NATS at %s: %w", r.natsURL, err) + } + r.natsConn = nc + default: + r.redisClient = redis.NewClient(&redis.Options{Addr: r.redisAddr}) + } + + slog.Info("Guild runtime started", "listen_addr", listenAddr, "backend", backend) + + // Seed agent registry into catalog + if err := r.seedAgentRegistry(); err != nil { + slog.Warn("Failed to seed agent registry", "error", err) + // Continue anyway - some agents might already be registered + } + + return nil +} + +// seedAgentRegistry loads the agent registry and registers all agents in the catalog +func (r *GuildRuntime) seedAgentRegistry() error { + registryPath := r.config.AgentRegistry + content, err := os.ReadFile(registryPath) + if err != nil { + return fmt.Errorf("failed to read registry: %w", err) + } + + var registry struct { + Entries []map[string]any `yaml:"entries"` + } + if err := yaml.Unmarshal(content, ®istry); err != nil { + return fmt.Errorf("failed to parse registry: %w", err) + } + + slog.Info("Seeding agent registry", "agent_count", len(registry.Entries)) + + // Register each agent + for _, agent := range registry.Entries { + className, _ := agent["class_name"].(string) + if className == "" { + continue + } + + // Convert to catalog agent entry format + agentEntry := map[string]any{ + "qualified_class_name": className, + "agent_name": agent["id"], // API expects agent_name + "agent_doc": agent["description"], + "agent_props_schema": map[string]any{}, + "message_handlers": map[string]any{}, + } + + // POST to /rustic/catalog/agents + if err := r.postJSON(r.rusticBase+"/catalog/agents", agentEntry, nil); err != nil { + // Ignore duplicate errors + if !strings.Contains(err.Error(), "409") && !strings.Contains(err.Error(), "duplicate") { + slog.Warn("Failed to register agent", "class_name", className, "error", err) + } + } + } + + return nil +} + +// LoadGuild loads a guild spec from a file +func (r *GuildRuntime) LoadGuild(specPath string) (*protocol.GuildSpec, error) { + // Read file and check if it's a blueprint wrapper + content, err := os.ReadFile(specPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Check if this is a blueprint wrapper (has a "spec" field). This is a + // best-effort JSON sniff: a parse failure (e.g. a YAML spec) simply leaves + // checker nil and falls through to the direct parser below. + var checker map[string]any + _ = json.Unmarshal(content, &checker) + + if specField, hasSpec := checker["spec"]; hasSpec && specField != nil { + // This is a blueprint wrapper - extract the nested spec + var wrapper struct { + Spec json.RawMessage `json:"spec"` + } + if err := json.Unmarshal(content, &wrapper); err != nil { + return nil, fmt.Errorf("failed to parse wrapper: %w", err) + } + + var nestedSpec protocol.GuildSpec + if err := json.Unmarshal(wrapper.Spec, &nestedSpec); err != nil { + return nil, fmt.Errorf("failed to parse nested spec: %w", err) + } + return &nestedSpec, nil + } + + // This is a direct guild spec + spec, _, err := guild.ParseFile(specPath) + if err != nil { + return nil, fmt.Errorf("failed to parse guild spec: %w", err) + } + return spec, nil +} + +// LaunchGuild launches a guild from a spec +func (r *GuildRuntime) LaunchGuild(spec *protocol.GuildSpec) (string, error) { + // Create blueprint in catalog + blueprintResp, err := r.createBlueprint(spec) + if err != nil { + return "", fmt.Errorf("failed to create blueprint: %w", err) + } + + blueprintID, ok := blueprintResp["id"].(string) + if !ok { + return "", fmt.Errorf("blueprint response missing id") + } + + // Launch guild from blueprint + guildID, err := r.launchFromBlueprint(blueprintID, spec.Name) + if err != nil { + return "", fmt.Errorf("failed to launch guild: %w", err) + } + + // Wait for guild to be running + if err := r.waitForGuildRunning(guildID, 2*time.Minute); err != nil { + return "", fmt.Errorf("guild did not start: %w", err) + } + + // Build agent name mapping for display + r.buildAgentNameMap(guildID, spec) + + slog.Info("Guild launched successfully", "guild_id", guildID, "name", spec.Name) + return guildID, nil +} + +// buildAgentNameMap creates a mapping from agent IDs to agent names +func (r *GuildRuntime) buildAgentNameMap(guildID string, spec *protocol.GuildSpec) { + // Add manager agent + r.agentNames[guildID+"#manager_agent"] = spec.Name + " Manager" + + // Query running agents to get their IDs + statuses, err := r.GetAgentStatuses(guildID) + if err != nil { + return + } + + // Map running agent IDs to their spec names. Agent IDs embed the agent name + // (e.g. "#"), so match on that rather than guessing by + // index: the previous implementation assigned spec.Agents[0].Name to every + // non-manager agent, mislabeling every guild with more than one agent. When + // the match is not unique we leave the ID unmapped and GetAgentName falls + // back to the raw ID instead of asserting a wrong name. + for agentID := range statuses { + if agentID == guildID+"#manager_agent" { + continue + } + + matched := "" + matches := 0 + for _, agent := range spec.Agents { + if agent.Name != "" && strings.Contains(agentID, agent.Name) { + matched = agent.Name + matches++ + } + } + if matches == 1 { + r.agentNames[agentID] = matched + } + } +} + +// GetAgentName returns the display name for an agent ID +func (r *GuildRuntime) GetAgentName(agentID string) string { + if name, ok := r.agentNames[agentID]; ok { + return name + } + return agentID +} + +// GetAgentStatuses gets the status of all agents in a guild +func (r *GuildRuntime) GetAgentStatuses(guildID string) (map[string]AgentStatus, error) { + if r.natsConn != nil { + return r.getAgentStatusesNATS(guildID) + } + return r.getAgentStatusesRedis(guildID) +} + +// getAgentStatusesRedis reads agent runtime status from the Redis keys the +// supervisor writes (forge:agent:status::). +func (r *GuildRuntime) getAgentStatusesRedis(guildID string) (map[string]AgentStatus, error) { + const statusPrefix = "forge:agent:status:" + pattern := fmt.Sprintf("%s%s:*", statusPrefix, guildID) + + keys, err := r.redisClient.Keys(r.ctx, pattern).Result() + if err != nil { + return nil, fmt.Errorf("failed to query agent statuses: %w", err) + } + + statuses := make(map[string]AgentStatus) + for _, key := range keys { + raw, err := r.redisClient.Get(r.ctx, key).Result() + if err != nil { + continue + } + + var status struct { + State string `json:"state"` + PID int `json:"pid"` + } + if err := json.Unmarshal([]byte(raw), &status); err != nil { + continue + } + + agentID := strings.TrimPrefix(key, statusPrefix+guildID+":") + statuses[agentID] = AgentStatus{ + AgentID: agentID, + State: status.State, + PID: status.PID, + } + } + + return statuses, nil +} + +// getAgentStatusesNATS reads agent runtime status from the JetStream KV bucket +// the supervisor writes under the NATS backend. Keys are "." +// with each component sanitized; there is no wildcard GET, so we list all keys +// in the bucket and filter by the guild prefix. +func (r *GuildRuntime) getAgentStatusesNATS(guildID string) (map[string]AgentStatus, error) { + js, err := r.natsConn.JetStream() + if err != nil { + return nil, fmt.Errorf("failed to get JetStream context: %w", err) + } + + kv, err := js.KeyValue(agentStatusKVBucket) + if err != nil { + // The bucket is created lazily by the server when the first agent writes + // its status; until then there are simply no statuses to report. + if errors.Is(err, nats.ErrBucketNotFound) { + return map[string]AgentStatus{}, nil + } + return nil, fmt.Errorf("failed to open agent-status KV bucket: %w", err) + } + + keys, err := kv.Keys() + if err != nil { + if errors.Is(err, nats.ErrNoKeysFound) { + return map[string]AgentStatus{}, nil + } + return nil, fmt.Errorf("failed to list agent statuses: %w", err) + } + + prefix := kvSanitize(guildID) + "." + statuses := make(map[string]AgentStatus) + for _, key := range keys { + if !strings.HasPrefix(key, prefix) { + continue + } + + entry, err := kv.Get(key) + if err != nil { + continue + } + + var status struct { + State string `json:"state"` + PID int `json:"pid"` + } + if err := json.Unmarshal(entry.Value(), &status); err != nil { + continue + } + + agentID := strings.TrimPrefix(key, prefix) + statuses[agentID] = AgentStatus{ + AgentID: agentID, + State: status.State, + PID: status.PID, + } + } + + return statuses, nil +} + +// kvSanitize mirrors the supervisor's NATS KV key sanitization: NATS KV keys +// allow only alphanumerics, '-', '_', and '.', so any other rune is replaced +// with '_'. Must stay in sync with supervisor.kvSanitize. +func kvSanitize(name string) string { + return strings.Map(func(rn rune) rune { + if (rn >= 'a' && rn <= 'z') || (rn >= 'A' && rn <= 'Z') || (rn >= '0' && rn <= '9') || rn == '-' || rn == '_' || rn == '.' { + return rn + } + return '_' + }, name) +} + +// PublishMessage publishes a message to a topic +func (r *GuildRuntime) PublishMessage(namespace, topic string, msg *protocol.Message) error { + backend, err := r.getMessagingBackend() + if err != nil { + return err + } + + return backend.PublishMessage(r.ctx, namespace, topic, msg) +} + +// getMessagingBackend returns the messaging backend for the runtime, matching +// the transport the server was launched with (NATS or Redis). The backend is +// built once and cached so publishers and subscribers share it. +func (r *GuildRuntime) getMessagingBackend() (messaging.Backend, error) { + if r.msgBackend != nil { + return r.msgBackend, nil + } + + if r.natsConn != nil { + backend, err := messaging.NewNATSBackend(r.natsConn) + if err != nil { + return nil, fmt.Errorf("failed to create NATS messaging backend: %w", err) + } + r.msgBackend = backend + return r.msgBackend, nil + } + + r.msgBackend = messaging.NewRedisBackend(r.redisClient) + return r.msgBackend, nil +} + +// Shutdown gracefully shuts down the runtime +func (r *GuildRuntime) Shutdown() error { + slog.Info("Shutting down guild runtime") + + r.cancel() + + if r.msgBackend != nil { + _ = r.msgBackend.Close() + } + + if r.redisClient != nil { + r.redisClient.Close() + } + + if r.natsConn != nil { + r.natsConn.Close() + } + + if r.serverCmd != nil && r.serverCmd.Process != nil { + r.kill() + } + + if r.tempDir != "" { + os.RemoveAll(r.tempDir) + } + + return nil +} + +// Helper functions + +func (r *GuildRuntime) waitForReady(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := http.Get(r.serverBase + "/readyz") + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + return nil + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for server ready") +} + +func (r *GuildRuntime) waitForGuildRunning(guildID string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + statuses, err := r.GetAgentStatuses(guildID) + if err == nil && len(statuses) > 0 { + allRunning := true + for _, status := range statuses { + if status.State != "running" { + allRunning = false + break + } + } + if allRunning { + return nil + } + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("timeout waiting for guild to be running") +} + +// guildStatus returns the guild's store-level status ("not_launched", +// "starting", "running", …) via the server's HTTP API. Unlike GetAgentStatuses +// (which reports process liveness, set the moment an agent process spawns), this +// reflects whether the manager has actually initialized the guild. +func (r *GuildRuntime) guildStatus(guildID string) (string, error) { + url := fmt.Sprintf("%s/api/guilds/%s", r.serverBase, guildID) + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var out struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.Status, nil +} + +// WaitForGuildReady blocks until the guild manager has finished initializing the +// guild, i.e. its store-level status reaches "running". The manager rejects a +// UserProxyAgent creation request with "Guild is not initialized" until this +// point, and agent-process liveness ("running" in the status store) is set at +// spawn time — well before the guild is initialized — so it cannot be used as +// the gate. This mirrors rustic-ui, which polls a guild health check and only +// creates the UserProxyAgent / enables messaging once the guild reports healthy. +// Returns nil once ready, or an error on timeout. +func (r *GuildRuntime) WaitForGuildReady(guildID string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + status, err := r.guildStatus(guildID) + if err == nil && status == string(store.GuildStatusRunning) { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for guild %s to become ready", guildID) +} + +func (r *GuildRuntime) createBlueprint(spec *protocol.GuildSpec) (map[string]any, error) { + // Convert spec to blueprint format + blueprint := map[string]any{ + "name": spec.Name, + "description": spec.Description, + "spec": spec, // API expects "spec" not "guild_spec" + "exposure": "public", // Make it public so we can launch it + "author_id": r.config.UserID, + "organization_id": r.config.OrgID, + } + + var result map[string]any + if err := r.postJSON(r.rusticBase+"/catalog/blueprints/", blueprint, &result); err != nil { + return nil, err + } + return result, nil +} + +func (r *GuildRuntime) launchFromBlueprint(blueprintID, guildName string) (string, error) { + launchReq := map[string]any{ + "guild_name": guildName, + "user_id": r.config.UserID, + "org_id": r.config.OrgID, + } + + var result map[string]any + url := fmt.Sprintf("%s/catalog/blueprints/%s/guilds", r.rusticBase, blueprintID) + if err := r.postJSON(url, launchReq, &result); err != nil { + return "", err + } + + // API returns {"id": "..."} not {"guild_id": "..."} + guildID, ok := result["id"].(string) + if !ok { + return "", fmt.Errorf("launch response missing id") + } + return guildID, nil +} + +func (r *GuildRuntime) postJSON(url string, payload, result any) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + if result != nil { + return json.Unmarshal(body, result) + } + return nil +} + +func (r *GuildRuntime) kill() { + if r.serverCmd != nil && r.serverCmd.Process != nil { + // Best-effort teardown: signal the server (its whole process group where + // supported), then reap it. Failures are logged rather than propagated, + // since there is nothing left to recover. + killProcessGroup(r.serverCmd) + if err := r.serverCmd.Wait(); err != nil { + slog.Debug("server process exited with error", "error", err) + } + } +} + +// reserveLocalAddr finds and reserves a free local TCP address +func reserveLocalAddr() (string, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", err + } + addr := listener.Addr().String() + listener.Close() + return addr, nil +} diff --git a/forge-go/cli/guild_runtime_methods_test.go b/forge-go/cli/guild_runtime_methods_test.go new file mode 100644 index 0000000..f56a0da --- /dev/null +++ b/forge-go/cli/guild_runtime_methods_test.go @@ -0,0 +1,329 @@ +package cli + +import ( + "context" + "net/http" + "os" + "testing" + "time" + + "github.com/rustic-ai/forge/forge-go/protocol" +) + +// --- redis-backed methods (miniredis) --- + +func TestGetAgentStatuses(t *testing.T) { + r, mr := newMiniredisRuntime(t) + guildID := "g1" + seedStatus(t, mr, statusKey(guildID, "g1#manager_agent"), `{"state":"running","pid":42}`) + seedStatus(t, mr, statusKey(guildID, "EchoAgent-abc"), `{"state":"starting","pid":7}`) + seedStatus(t, mr, statusKey(guildID, "broken"), `not-json`) + // A key for a different guild must not be returned. + seedStatus(t, mr, statusKey("other", "x"), `{"state":"running","pid":1}`) + + statuses, err := r.GetAgentStatuses(guildID) + if err != nil { + t.Fatalf("GetAgentStatuses: %v", err) + } + if len(statuses) != 2 { + t.Fatalf("expected 2 valid statuses, got %d (%v)", len(statuses), statuses) + } + if s := statuses["g1#manager_agent"]; s.State != "running" || s.PID != 42 { + t.Errorf("manager status = %+v, want running/42", s) + } + if s := statuses["EchoAgent-abc"]; s.State != "starting" || s.PID != 7 { + t.Errorf("echo status = %+v, want starting/7", s) + } +} + +func TestBuildAgentNameMap_UniqueMatchAndManager(t *testing.T) { + r, mr := newMiniredisRuntime(t) + guildID := "g1" + spec := &protocol.GuildSpec{Name: "Echo", Agents: []protocol.AgentSpec{{Name: "EchoAgent"}}} + seedStatus(t, mr, statusKey(guildID, guildID+"#manager_agent"), `{"state":"running","pid":1}`) + seedStatus(t, mr, statusKey(guildID, "EchoAgent-xyz"), `{"state":"running","pid":2}`) + + r.buildAgentNameMap(guildID, spec) + + if got := r.GetAgentName(guildID + "#manager_agent"); got != "Echo Manager" { + t.Errorf("manager name = %q, want %q", got, "Echo Manager") + } + if got := r.GetAgentName("EchoAgent-xyz"); got != "EchoAgent" { + t.Errorf("agent name = %q, want EchoAgent (unique substring match)", got) + } +} + +func TestBuildAgentNameMap_AmbiguousFallsBackToID(t *testing.T) { + r, mr := newMiniredisRuntime(t) + guildID := "g1" + // Both agent names are substrings of the runtime ID -> ambiguous -> no mapping. + spec := &protocol.GuildSpec{Name: "G", Agents: []protocol.AgentSpec{{Name: "Foo"}, {Name: "Bar"}}} + seedStatus(t, mr, statusKey(guildID, "FooBar-1"), `{"state":"running","pid":1}`) + + r.buildAgentNameMap(guildID, spec) + + if got := r.GetAgentName("FooBar-1"); got != "FooBar-1" { + t.Errorf("ambiguous agent should fall back to raw ID, got %q", got) + } +} + +func TestGetAgentName_Fallback(t *testing.T) { + r, _ := newMiniredisRuntime(t) + r.agentNames["known"] = "Friendly" + if got := r.GetAgentName("known"); got != "Friendly" { + t.Errorf("got %q, want Friendly", got) + } + if got := r.GetAgentName("unknown"); got != "unknown" { + t.Errorf("got %q, want raw id unknown", got) + } +} + +func TestWaitForGuildRunning(t *testing.T) { + r, mr := newMiniredisRuntime(t) + guildID := "g1" + seedStatus(t, mr, statusKey(guildID, "a1"), `{"state":"running","pid":1}`) + + if err := r.waitForGuildRunning(guildID, 2*time.Second); err != nil { + t.Errorf("expected running guild to succeed, got %v", err) + } + + // No statuses + an already-elapsed deadline -> immediate timeout error. + if err := r.waitForGuildRunning("empty", time.Nanosecond); err == nil { + t.Error("expected timeout error for a guild with no running agents") + } +} + +func TestPublishMessage(t *testing.T) { + r, _ := newMiniredisRuntime(t) + msg, err := BuildChatMessage("u", "U", "hi", "t") + if err != nil { + t.Fatalf("BuildChatMessage: %v", err) + } + if err := r.PublishMessage("g1", "default_topic", msg); err != nil { + t.Errorf("PublishMessage: %v", err) + } +} + +func TestGetMessagingBackend(t *testing.T) { + r, _ := newMiniredisRuntime(t) + backend, err := r.getMessagingBackend() + if err != nil { + t.Fatalf("getMessagingBackend: %v", err) + } + if backend == nil { + t.Error("expected non-nil messaging backend") + } +} + +// --- HTTP-backed methods (httptest) --- + +func TestPostJSON(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/ok", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"abc"}`)) + }) + mux.HandleFunc("/bad", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`boom`)) + }) + r := newHTTPRuntime(t, mux) + + var result map[string]any + if err := r.postJSON(r.rusticBase+"/ok", map[string]any{"x": 1}, &result); err != nil { + t.Fatalf("postJSON /ok: %v", err) + } + if result["id"] != "abc" { + t.Errorf("decoded result = %v, want id=abc", result) + } + + err := r.postJSON(r.rusticBase+"/bad", map[string]any{}, nil) + if err == nil || !contains(err.Error(), "HTTP 400") { + t.Errorf("expected HTTP 400 error, got %v", err) + } + + // Transport error: nothing listening on this address. + if err := r.postJSON("http://127.0.0.1:1/x", map[string]any{}, nil); err == nil { + t.Error("expected transport error for unreachable host") + } +} + +func TestCreateAndLaunchBlueprint(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/catalog/blueprints/", func(w http.ResponseWriter, req *http.Request) { + // createBlueprint POSTs to /catalog/blueprints/ ; launch POSTs to + // /catalog/blueprints//guilds — both handled by this prefix. + if req.URL.Path == "/catalog/blueprints/" { + _, _ = w.Write([]byte(`{"id":"bp-1"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"guild-1"}`)) + }) + r := newHTTPRuntime(t, mux) + + bp, err := r.createBlueprint(&protocol.GuildSpec{Name: "N", Description: "d"}) + if err != nil { + t.Fatalf("createBlueprint: %v", err) + } + if bp["id"] != "bp-1" { + t.Fatalf("blueprint id = %v, want bp-1", bp["id"]) + } + + guildID, err := r.launchFromBlueprint("bp-1", "N") + if err != nil { + t.Fatalf("launchFromBlueprint: %v", err) + } + if guildID != "guild-1" { + t.Errorf("guild id = %q, want guild-1", guildID) + } +} + +func TestLaunchGuild(t *testing.T) { + // LaunchGuild needs both HTTP (blueprint create/launch) and redis + // (waitForGuildRunning / buildAgentNameMap), so wire a runtime with both. + mr, cancel := newMiniredis(t) + defer cancel() + mux := http.NewServeMux() + mux.HandleFunc("/catalog/blueprints/", func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/catalog/blueprints/" { + _, _ = w.Write([]byte(`{"id":"bp-1"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"guild-1"}`)) + }) + r := newHTTPRuntime(t, mux) + r.redisClient = newRedisClient(t, mr) + // Pre-seed the launched guild's agent as running so waitForGuildRunning + // returns immediately. + seedStatus(t, mr, statusKey("guild-1", "guild-1#manager_agent"), `{"state":"running","pid":1}`) + + spec := &protocol.GuildSpec{Name: "Echo", Agents: []protocol.AgentSpec{{Name: "EchoAgent"}}} + guildID, err := r.LaunchGuild(spec) + if err != nil { + t.Fatalf("LaunchGuild: %v", err) + } + if guildID != "guild-1" { + t.Errorf("guild id = %q, want guild-1", guildID) + } +} + +func TestSeedAgentRegistry(t *testing.T) { + var posted int + mux := http.NewServeMux() + mux.HandleFunc("/catalog/agents", func(w http.ResponseWriter, _ *http.Request) { + posted++ + w.WriteHeader(http.StatusCreated) + }) + r := newHTTPRuntime(t, mux) + + registry := "entries:\n" + + " - id: AgentA\n class_name: pkg.AgentA\n description: a\n" + + " - id: AgentB\n class_name: pkg.AgentB\n description: b\n" + + " - id: NoClass\n description: skipped\n" // no class_name -> skipped + r.config.AgentRegistry = writeTemp(t, "registry.yaml", registry) + + if err := r.seedAgentRegistry(); err != nil { + t.Fatalf("seedAgentRegistry: %v", err) + } + if posted != 2 { + t.Errorf("posted %d agents, want 2 (entry without class_name skipped)", posted) + } +} + +func TestSeedAgentRegistry_DuplicateTolerated(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/catalog/agents", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) // 409 -> tolerated, not an error + }) + r := newHTTPRuntime(t, mux) + r.config.AgentRegistry = writeTemp(t, "registry.yaml", + "entries:\n - id: A\n class_name: pkg.A\n description: a\n") + + if err := r.seedAgentRegistry(); err != nil { + t.Errorf("409 conflicts should be tolerated, got %v", err) + } +} + +func TestWaitForReady(t *testing.T) { + r := newHTTPRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/readyz" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + if err := r.waitForReady(2 * time.Second); err != nil { + t.Errorf("waitForReady: %v", err) + } +} + +func TestWaitForReady_Timeout(t *testing.T) { + r := newHTTPRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + if err := r.waitForReady(250 * time.Millisecond); err == nil { + t.Error("expected timeout error when server never reports ready") + } +} + +// --- pure / filesystem methods --- + +func TestNewGuildRuntime_Defaults(t *testing.T) { + r, err := NewGuildRuntime(RuntimeConfig{}) + if err != nil { + t.Fatalf("NewGuildRuntime: %v", err) + } + t.Cleanup(func() { _ = r.Shutdown() }) + + if r.config.Backend != "nats" || r.config.OrgID != "local-dev" || + r.config.UserID != "test-user" || r.config.SupervisorType != "process" { + t.Errorf("defaults not applied: %+v", r.config) + } + if _, err := os.Stat(r.dataDir); err != nil { + t.Errorf("data dir not created: %v", err) + } +} + +func TestReserveLocalAddr(t *testing.T) { + a, err := reserveLocalAddr() + if err != nil { + t.Fatalf("reserveLocalAddr: %v", err) + } + b, err := reserveLocalAddr() + if err != nil { + t.Fatalf("reserveLocalAddr: %v", err) + } + if a == "" || b == "" { + t.Error("expected non-empty addresses") + } + if a == b { + t.Errorf("expected distinct addresses, both = %s", a) + } +} + +func TestShutdown_NoServer(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + dir, err := os.MkdirTemp("", "shutdown-test-*") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + r := &GuildRuntime{ctx: ctx, cancel: cancel, tempDir: dir} + + if err := r.Shutdown(); err != nil { + t.Errorf("Shutdown: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("expected temp dir removed, stat err = %v", err) + } +} + +// contains reports whether substr is within s (avoids importing strings just +// for this file). +func contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return len(substr) == 0 +} diff --git a/forge-go/cli/guild_runtime_test.go b/forge-go/cli/guild_runtime_test.go new file mode 100644 index 0000000..d59b980 --- /dev/null +++ b/forge-go/cli/guild_runtime_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +// writeTemp writes content to a file named name inside a fresh temp dir and +// returns its path. +func writeTemp(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + return path +} + +func TestLoadGuild_DirectJSONSpec(t *testing.T) { + path := writeTemp(t, "guild.json", `{"name":"Echo Guild","description":"d","agents":[]}`) + + spec, err := (&GuildRuntime{}).LoadGuild(path) + if err != nil { + t.Fatalf("LoadGuild returned error: %v", err) + } + if spec.Name != "Echo Guild" { + t.Fatalf("expected name %q, got %q", "Echo Guild", spec.Name) + } +} + +func TestLoadGuild_BlueprintWrapper(t *testing.T) { + // A blueprint wrapper nests the guild under a "spec" field; LoadGuild must + // unwrap it and return the inner spec, not the wrapper. + path := writeTemp(t, "blueprint.json", + `{"name":"Wrapper","exposure":"public","spec":{"name":"Inner Guild","description":"d","agents":[]}}`) + + spec, err := (&GuildRuntime{}).LoadGuild(path) + if err != nil { + t.Fatalf("LoadGuild returned error: %v", err) + } + if spec.Name != "Inner Guild" { + t.Fatalf("expected unwrapped name %q, got %q", "Inner Guild", spec.Name) + } +} + +func TestLoadGuild_YAMLSpecFallsThrough(t *testing.T) { + // YAML is not valid JSON, so the blueprint sniff fails and LoadGuild must + // fall through to the extension-based parser. + path := writeTemp(t, "guild.yaml", "name: YAML Guild\ndescription: d\nagents: []\n") + + spec, err := (&GuildRuntime{}).LoadGuild(path) + if err != nil { + t.Fatalf("LoadGuild returned error: %v", err) + } + if spec.Name != "YAML Guild" { + t.Fatalf("expected name %q, got %q", "YAML Guild", spec.Name) + } +} + +func TestLoadGuild_MissingFile(t *testing.T) { + _, err := (&GuildRuntime{}).LoadGuild(filepath.Join(t.TempDir(), "does-not-exist.json")) + if err == nil { + t.Fatal("expected an error for a missing file, got nil") + } +} + +func TestLoadGuild_UnsupportedExtension(t *testing.T) { + // Not a blueprint (no "spec") and an extension ParseFile rejects. + path := writeTemp(t, "guild.txt", "not a guild") + _, err := (&GuildRuntime{}).LoadGuild(path) + if err == nil { + t.Fatal("expected an error for an unsupported extension, got nil") + } +} diff --git a/forge-go/cli/message_builder.go b/forge-go/cli/message_builder.go new file mode 100644 index 0000000..e185bee --- /dev/null +++ b/forge-go/cli/message_builder.go @@ -0,0 +1,223 @@ +package cli + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/rustic-ai/forge/forge-go/protocol" +) + +var ( + idGen *protocol.GemstoneGenerator + idGenErr error + idGenOnce sync.Once +) + +func getIDGen() (*protocol.GemstoneGenerator, error) { + idGenOnce.Do(func() { + // Use machine ID 1 for CLI + idGen, idGenErr = protocol.NewGemstoneGenerator(1) + }) + return idGen, idGenErr +} + +// BuildChatMessage creates a chatCompletionRequest message +func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, error) { + gen, err := getIDGen() + if err != nil { + return nil, fmt.Errorf("failed to init ID generator: %w", err) + } + msgID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate message ID: %w", err) + } + + // Create chat completion request payload + payload := map[string]any{ + "messages": []any{ + map[string]any{ + "role": "user", + "name": userID, + "content": []any{ + map[string]any{ + "type": "text", + "text": text, + }, + }, + }, + }, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + convID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate conversation ID: %w", err) + } + convIDInt := convID.ToInt() + + msg := protocol.NewMessageFromGemstoneID(msgID) + msg.Format = "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionRequest" // Use full format to match routing rules + msg.Sender = protocol.AgentTag{ + ID: &userID, + Name: &userName, + } + msg.Topics = protocol.TopicsFromString(topic) + msg.Payload = json.RawMessage(payloadBytes) + msg.ConversationID = &convIDInt + + return &msg, nil +} + +// BuildSystemMessage creates a system message +func BuildSystemMessage(topic string, payload map[string]any, format string) (*protocol.Message, error) { + gen, err := getIDGen() + if err != nil { + return nil, fmt.Errorf("failed to init ID generator: %w", err) + } + msgID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate message ID: %w", err) + } + + if format == "" { + format = "system" + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + msg := protocol.NewMessageFromGemstoneID(msgID) + msg.Format = format + msg.Topics = protocol.TopicsFromString(topic) + msg.Payload = json.RawMessage(payloadBytes) + + return &msg, nil +} + +// ParseMessageFromJSON parses a message from JSON +func ParseMessageFromJSON(data []byte) (*protocol.Message, error) { + var msg protocol.Message + if err := json.Unmarshal(data, &msg); err != nil { + return nil, fmt.Errorf("failed to parse message: %w", err) + } + return &msg, nil +} + +// BuildHealthCheckMessage creates a health check message +func BuildHealthCheckMessage(userID, topic string) (*protocol.Message, error) { + gen, err := getIDGen() + if err != nil { + return nil, fmt.Errorf("failed to init ID generator: %w", err) + } + msgID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate message ID: %w", err) + } + + payload := map[string]any{ + "dummy": 1, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + convID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate conversation ID: %w", err) + } + convIDInt := convID.ToInt() + + msg := protocol.NewMessageFromGemstoneID(msgID) + msg.Format = "healthcheck" + msg.Sender = protocol.AgentTag{ + ID: &userID, + } + msg.Topics = protocol.TopicsFromString(topic) + msg.Payload = json.RawMessage(payloadBytes) + msg.ConversationID = &convIDInt + + return &msg, nil +} + +// BuildWrappedChatMessage creates a wrapped chat message for UserProxyAgent +// This mimics how the gateway wraps user messages before sending them to user:{userID} +func BuildWrappedChatMessage(userID, userName, text string) (*protocol.Message, error) { + gen, err := getIDGen() + if err != nil { + return nil, fmt.Errorf("failed to init ID generator: %w", err) + } + + // Create the inner chat message + innerMsg, err := BuildChatMessage(userID, userName, text, "default_topic") + if err != nil { + return nil, fmt.Errorf("failed to build inner message: %w", err) + } + + // Generate a new ID for the wrapper + wrapperID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate wrapper ID: %w", err) + } + + // Marshal the inner message as the payload + innerBytes, err := json.Marshal(innerMsg) + if err != nil { + return nil, fmt.Errorf("failed to marshal inner message: %w", err) + } + + // Create the wrapper message + senderID := fmt.Sprintf("user_socket:%s", userID) + wrapper := protocol.NewMessageFromGemstoneID(wrapperID) + wrapper.Format = "rustic_ai.core.messaging.core.message.Message" + wrapper.Sender = protocol.AgentTag{ + ID: &senderID, + Name: &userName, + } + wrapper.Topics = protocol.TopicsFromString(fmt.Sprintf("user:%s", userID)) + wrapper.Payload = json.RawMessage(innerBytes) + wrapper.Thread = []uint64{wrapperID.ToInt()} + + return &wrapper, nil +} + +// BuildUserProxyCreationRequest creates a UserAgentCreationRequest message +// This triggers the manager to create a UserProxyAgent for the user +func BuildUserProxyCreationRequest(userID, userName string) (*protocol.Message, error) { + gen, err := getIDGen() + if err != nil { + return nil, fmt.Errorf("failed to init ID generator: %w", err) + } + msgID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate message ID: %w", err) + } + + payload := map[string]any{ + "user_id": userID, + "user_name": userName, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + senderID := fmt.Sprintf("user_socket:%s", userID) + msg := protocol.NewMessageFromGemstoneID(msgID) + msg.Format = "rustic_ai.core.agents.system.models.UserAgentCreationRequest" + msg.Sender = protocol.AgentTag{ + ID: &senderID, + Name: &userName, + } + msg.Topics = protocol.TopicsFromString("system_topic") + msg.Payload = json.RawMessage(payloadBytes) + + return &msg, nil +} diff --git a/forge-go/cli/message_builder_test.go b/forge-go/cli/message_builder_test.go new file mode 100644 index 0000000..3058611 --- /dev/null +++ b/forge-go/cli/message_builder_test.go @@ -0,0 +1,203 @@ +package cli + +import ( + "encoding/json" + "testing" + + "github.com/rustic-ai/forge/forge-go/protocol" +) + +func TestBuildChatMessage(t *testing.T) { + msg, err := BuildChatMessage("alice", "Alice", "hello world", "default_topic") + if err != nil { + t.Fatalf("BuildChatMessage: %v", err) + } + + if msg.Format != "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionRequest" { + t.Errorf("unexpected format: %s", msg.Format) + } + if msg.ID == 0 { + t.Error("expected non-zero message ID") + } + if msg.ConversationID == nil { + t.Error("expected ConversationID to be set") + } + if got := derefStr(msg.Sender.ID); got != "alice" { + t.Errorf("sender ID = %q, want alice", got) + } + if got := derefStr(msg.Sender.Name); got != "Alice" { + t.Errorf("sender name = %q, want Alice", got) + } + if topics := msg.Topics.ToSlice(); len(topics) != 1 || topics[0] != "default_topic" { + t.Errorf("topics = %v, want [default_topic]", topics) + } + + // Payload: messages[0] with role=user, name=alice, content[0].text=hello world. + var payload map[string]any + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + messages, _ := payload["messages"].([]any) + if len(messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(messages)) + } + first, _ := messages[0].(map[string]any) + if first["role"] != "user" { + t.Errorf("role = %v, want user", first["role"]) + } + if first["name"] != "alice" { + t.Errorf("name = %v, want alice", first["name"]) + } + content, _ := first["content"].([]any) + if len(content) != 1 { + t.Fatalf("expected 1 content item, got %d", len(content)) + } + item, _ := content[0].(map[string]any) + if item["type"] != "text" || item["text"] != "hello world" { + t.Errorf("content[0] = %v, want type=text text=hello world", item) + } +} + +func TestBuildSystemMessage_DefaultAndExplicitFormat(t *testing.T) { + payload := map[string]any{"k": "v"} + + def, err := BuildSystemMessage("system_topic", payload, "") + if err != nil { + t.Fatalf("BuildSystemMessage: %v", err) + } + if def.Format != "system" { + t.Errorf("default format = %q, want system", def.Format) + } + if topics := def.Topics.ToSlice(); len(topics) != 1 || topics[0] != "system_topic" { + t.Errorf("topics = %v, want [system_topic]", topics) + } + + explicit, err := BuildSystemMessage("t", payload, "custom.Format") + if err != nil { + t.Fatalf("BuildSystemMessage: %v", err) + } + if explicit.Format != "custom.Format" { + t.Errorf("explicit format = %q, want custom.Format", explicit.Format) + } + var got map[string]any + if err := json.Unmarshal(explicit.Payload, &got); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if got["k"] != "v" { + t.Errorf("payload = %v, want {k:v}", got) + } +} + +func TestBuildHealthCheckMessage(t *testing.T) { + msg, err := BuildHealthCheckMessage("bob", "user:bob") + if err != nil { + t.Fatalf("BuildHealthCheckMessage: %v", err) + } + if msg.Format != "healthcheck" { + t.Errorf("format = %q, want healthcheck", msg.Format) + } + if derefStr(msg.Sender.ID) != "bob" { + t.Errorf("sender ID = %q, want bob", derefStr(msg.Sender.ID)) + } + if msg.ConversationID == nil { + t.Error("expected ConversationID to be set") + } + if topics := msg.Topics.ToSlice(); len(topics) != 1 || topics[0] != "user:bob" { + t.Errorf("topics = %v, want [user:bob]", topics) + } +} + +func TestBuildWrappedChatMessage(t *testing.T) { + msg, err := BuildWrappedChatMessage("alice", "Alice", "hi") + if err != nil { + t.Fatalf("BuildWrappedChatMessage: %v", err) + } + if msg.Format != "rustic_ai.core.messaging.core.message.Message" { + t.Errorf("format = %q", msg.Format) + } + if got := derefStr(msg.Sender.ID); got != "user_socket:alice" { + t.Errorf("sender ID = %q, want user_socket:alice", got) + } + if topics := msg.Topics.ToSlice(); len(topics) != 1 || topics[0] != "user:alice" { + t.Errorf("topics = %v, want [user:alice]", topics) + } + if len(msg.Thread) != 1 { + t.Errorf("thread len = %d, want 1", len(msg.Thread)) + } + + // The payload is the inner chat message. + var inner protocol.Message + if err := json.Unmarshal(msg.Payload, &inner); err != nil { + t.Fatalf("unmarshal inner message: %v", err) + } + if inner.Format != "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionRequest" { + t.Errorf("inner format = %q", inner.Format) + } +} + +func TestBuildUserProxyCreationRequest(t *testing.T) { + msg, err := BuildUserProxyCreationRequest("alice", "Alice") + if err != nil { + t.Fatalf("BuildUserProxyCreationRequest: %v", err) + } + if msg.Format != "rustic_ai.core.agents.system.models.UserAgentCreationRequest" { + t.Errorf("format = %q", msg.Format) + } + if got := derefStr(msg.Sender.ID); got != "user_socket:alice" { + t.Errorf("sender ID = %q, want user_socket:alice", got) + } + if topics := msg.Topics.ToSlice(); len(topics) != 1 || topics[0] != "system_topic" { + t.Errorf("topics = %v, want [system_topic]", topics) + } + var payload map[string]any + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload["user_id"] != "alice" || payload["user_name"] != "Alice" { + t.Errorf("payload = %v, want user_id=alice user_name=Alice", payload) + } +} + +func TestParseMessageFromJSON(t *testing.T) { + orig, err := BuildChatMessage("alice", "Alice", "hi", "t") + if err != nil { + t.Fatalf("BuildChatMessage: %v", err) + } + raw, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + parsed, err := ParseMessageFromJSON(raw) + if err != nil { + t.Fatalf("ParseMessageFromJSON: %v", err) + } + if parsed.ID != orig.ID || parsed.Format != orig.Format { + t.Errorf("round-trip mismatch: got id=%d format=%s", parsed.ID, parsed.Format) + } + + if _, err := ParseMessageFromJSON([]byte("not json")); err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestMessageIDsAreUnique(t *testing.T) { + a, err := BuildChatMessage("u", "U", "one", "t") + if err != nil { + t.Fatalf("BuildChatMessage: %v", err) + } + b, err := BuildChatMessage("u", "U", "two", "t") + if err != nil { + t.Fatalf("BuildChatMessage: %v", err) + } + if a.ID == b.ID { + t.Errorf("expected unique message IDs, both = %d", a.ID) + } +} + +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/forge-go/cli/process_unix.go b/forge-go/cli/process_unix.go new file mode 100644 index 0000000..77fae86 --- /dev/null +++ b/forge-go/cli/process_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package cli + +import ( + "log/slog" + "os/exec" + "syscall" +) + +// setProcessGroup starts the child in its own process group so that the whole +// tree can be signalled together during teardown. +func setProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// killProcessGroup SIGKILLs the child's entire process group. It is best-effort: +// failures are logged rather than returned, since it runs during teardown. +func killProcessGroup(cmd *exec.Cmd) { + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + slog.Debug("failed to signal server process group", "error", err) + } +} diff --git a/forge-go/cli/process_windows.go b/forge-go/cli/process_windows.go new file mode 100644 index 0000000..dfe98aa --- /dev/null +++ b/forge-go/cli/process_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package cli + +import ( + "log/slog" + "os/exec" +) + +// setProcessGroup is a no-op on Windows, which has no POSIX process groups. +func setProcessGroup(cmd *exec.Cmd) {} + +// killProcessGroup terminates the server process on Windows. Without POSIX +// process groups any grandchildren are not reaped here; it is best-effort and +// runs during teardown, so failures are only logged. +func killProcessGroup(cmd *exec.Cmd) { + if err := cmd.Process.Kill(); err != nil { + slog.Debug("failed to kill server process", "error", err) + } +} diff --git a/forge-go/cli/subscription.go b/forge-go/cli/subscription.go new file mode 100644 index 0000000..f36f654 --- /dev/null +++ b/forge-go/cli/subscription.go @@ -0,0 +1,120 @@ +package cli + +import ( + "context" + "fmt" + "sync" + + "github.com/rustic-ai/forge/forge-go/messaging" + "github.com/rustic-ai/forge/forge-go/protocol" +) + +// GuildSubscription wraps a messaging subscription for guild messages +type GuildSubscription struct { + sub messaging.Subscription + msgChan chan *protocol.Message + errChan chan error + ctx context.Context + cancel context.CancelFunc + closeOnce sync.Once +} + +// Subscribe creates a subscription to guild message topics +func (r *GuildRuntime) Subscribe(guildID, userID string, spec *protocol.GuildSpec) (*GuildSubscription, error) { + // Subscribe to relevant topics + topics := []string{ + "user_notifications:" + userID, // Messages to user + "user_system_notification:" + userID, // System notifications + "user_message_broadcast", // Broadcast messages from agents + "user:" + userID, // Direct user messages + "default_topic", // Guild default inbox + "guild_status_topic", // Guild status + "infra_events_topic", // Infrastructure events + } + + // Add all additional_topics from agents in the guild + for _, agent := range spec.Agents { + topics = append(topics, agent.AdditionalTopics...) + } + + backend, err := r.getMessagingBackend() + if err != nil { + return nil, fmt.Errorf("failed to get messaging backend: %w", err) + } + + sub, err := backend.Subscribe(r.ctx, guildID, topics...) + if err != nil { + return nil, fmt.Errorf("failed to subscribe: %w", err) + } + + ctx, cancel := context.WithCancel(r.ctx) + guildSub := &GuildSubscription{ + sub: sub, + msgChan: make(chan *protocol.Message, 100), + errChan: make(chan error, 10), + ctx: ctx, + cancel: cancel, + } + + // Start message forwarding goroutine + go guildSub.forward() + + return guildSub, nil +} + +// Messages returns the channel for receiving messages +func (s *GuildSubscription) Messages() <-chan *protocol.Message { + return s.msgChan +} + +// Errors returns the channel for receiving errors +func (s *GuildSubscription) Errors() <-chan error { + return s.errChan +} + +// Close closes the subscription. It cancels the forwarding goroutine and closes +// the underlying subscription; msgChan/errChan are closed by forward() as it +// exits (it is their sole writer), so Close never closes them itself — doing so +// would race with an in-flight send and panic. Safe to call more than once. +func (s *GuildSubscription) Close() error { + var err error + s.closeOnce.Do(func() { + s.cancel() + err = s.sub.Close() + }) + return err +} + +// forward pumps messages and errors from the underlying subscription into the +// buffered channels until the context is cancelled or the source closes. It is +// the sole writer to msgChan/errChan and therefore the sole closer: closing them +// here (rather than in Close) guarantees no send races with a close. Every send +// is guarded by ctx.Done() so a stalled consumer cannot leak this goroutine. +func (s *GuildSubscription) forward() { + defer close(s.msgChan) + defer close(s.errChan) + for { + select { + case <-s.ctx.Done(): + return + case subMsg, ok := <-s.sub.Channel(): + if !ok { + return + } + select { + case s.msgChan <- subMsg.Message: + case <-s.ctx.Done(): + return + } + case err, ok := <-s.sub.ErrChannel(): + if !ok { + return + } + select { + case s.errChan <- err: + case <-s.ctx.Done(): + return + } + } + } +} diff --git a/forge-go/cli/subscription_test.go b/forge-go/cli/subscription_test.go new file mode 100644 index 0000000..ba052f4 --- /dev/null +++ b/forge-go/cli/subscription_test.go @@ -0,0 +1,173 @@ +package cli + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/rustic-ai/forge/forge-go/messaging" + "github.com/rustic-ai/forge/forge-go/protocol" +) + +// fakeSubscription is a controllable messaging.Subscription for exercising the +// forward()/Close() concurrency of GuildSubscription in isolation. Close() is a +// no-op on the channels: the real subscriptions signal shutdown by closing their +// channels, but GuildSubscription.forward() also exits on context cancellation, +// and leaving the channels open here avoids a send-on-closed-channel race with a +// concurrent test producer. Use closeSource() to exercise the source-closed path. +type fakeSubscription struct { + ch chan messaging.SubMessage + errCh chan error + closeOnce sync.Once +} + +func newFakeSubscription() *fakeSubscription { + return &fakeSubscription{ + ch: make(chan messaging.SubMessage), + errCh: make(chan error), + } +} + +func (f *fakeSubscription) Channel() <-chan messaging.SubMessage { return f.ch } +func (f *fakeSubscription) ErrChannel() <-chan error { return f.errCh } +func (f *fakeSubscription) Close() error { return nil } + +// closeSource simulates the underlying transport closing its delivery channels. +func (f *fakeSubscription) closeSource() { + f.closeOnce.Do(func() { + close(f.ch) + close(f.errCh) + }) +} + +func newTestSubscription(sub messaging.Subscription, msgBuf, errBuf int) *GuildSubscription { + ctx, cancel := context.WithCancel(context.Background()) + return &GuildSubscription{ + sub: sub, + msgChan: make(chan *protocol.Message, msgBuf), + errChan: make(chan error, errBuf), + ctx: ctx, + cancel: cancel, + } +} + +// TestClose_DuringActiveForwarding asserts that closing the subscription while +// messages are actively being forwarded never panics with "send on closed +// channel". This is the regression: previously Close() closed msgChan/errChan +// while forward() could still be mid-send. Run with -race for full coverage. +func TestClose_DuringActiveForwarding(t *testing.T) { + fs := newFakeSubscription() + s := newTestSubscription(fs, 100, 10) + go s.forward() + + // Drain the consumer side so the buffer is not the thing preventing sends. + drained := make(chan struct{}) + go func() { + defer close(drained) + for range s.Messages() { + } + }() + + // Producer: push messages until the context is cancelled by Close(). + producerDone := make(chan struct{}) + go func() { + defer close(producerDone) + for { + select { + case fs.ch <- messaging.SubMessage{Message: &protocol.Message{}}: + case <-s.ctx.Done(): + return + } + } + }() + + // Let a few messages flow, then close concurrently with active forwarding. + time.Sleep(5 * time.Millisecond) + if err := s.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + // Close is idempotent. + if err := s.Close(); err != nil { + t.Fatalf("second Close returned error: %v", err) + } + + waitClosed(t, producerDone, "producer did not observe context cancellation") + waitClosed(t, drained, "msgChan was never closed after Close") +} + +// TestClose_UnblocksStalledForward asserts that a forward() goroutine blocked on +// a send (because the consumer stopped reading and the buffer filled) is released +// by Close() rather than leaking forever. +func TestClose_UnblocksStalledForward(t *testing.T) { + fs := newFakeSubscription() + s := newTestSubscription(fs, 1, 1) // tiny buffers so the second send blocks + + go s.forward() + + // First message lands in the size-1 buffer; the second leaves forward blocked + // on the guarded send with no consumer reading. + fs.ch <- messaging.SubMessage{Message: &protocol.Message{}} + fs.ch <- messaging.SubMessage{Message: &protocol.Message{}} + + // Close must cancel the context, unblock the stalled send, and let forward + // exit — which closes msgChan. + if err := s.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + drained := make(chan struct{}) + go func() { + defer close(drained) + for range s.Messages() { + } + }() + waitClosed(t, drained, "forward goroutine leaked: msgChan never closed after Close") +} + +// TestForward_ExitsWhenSourceCloses asserts that forward() returns (and closes +// its output channels) when the underlying subscription closes its channels, +// even without an explicit Close() call. +func TestForward_ExitsWhenSourceCloses(t *testing.T) { + fs := newFakeSubscription() + s := newTestSubscription(fs, 1, 1) + go s.forward() + + fs.closeSource() + + drained := make(chan struct{}) + go func() { + defer close(drained) + for range s.Messages() { + } + }() + waitClosed(t, drained, "forward did not exit when the source channel closed") +} + +func waitClosed(t *testing.T, done <-chan struct{}, msg string) { + t.Helper() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal(msg) + } +} + +func TestSubscribe(t *testing.T) { + r, _ := newMiniredisRuntime(t) + spec := &protocol.GuildSpec{ + Agents: []protocol.AgentSpec{{Name: "A", AdditionalTopics: []string{"extra_topic"}}}, + } + + sub, err := r.Subscribe("g1", "u1", spec) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + if sub.Messages() == nil || sub.Errors() == nil { + t.Error("expected non-nil Messages/Errors channels") + } + if err := sub.Close(); err != nil { + t.Errorf("Close: %v", err) + } +} diff --git a/forge-go/cli/testhelpers_test.go b/forge-go/cli/testhelpers_test.go new file mode 100644 index 0000000..de7420c --- /dev/null +++ b/forge-go/cli/testhelpers_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +// newMiniredis starts an in-memory redis and returns it plus a stop func. +func newMiniredis(t *testing.T) (*miniredis.Miniredis, func()) { + t.Helper() + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis.Run: %v", err) + } + return mr, mr.Close +} + +// newRedisClient builds a *redis.Client pointed at mr, closed on test cleanup. +func newRedisClient(t *testing.T, mr *miniredis.Miniredis) *redis.Client { + t.Helper() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + return rdb +} + +// newMiniredisRuntime returns a GuildRuntime wired to an in-memory miniredis +// instance, suitable for exercising the redis-backed methods. The miniredis +// handle is returned so tests can seed keys directly (mr.Set(...)). +func newMiniredisRuntime(t *testing.T) (*GuildRuntime, *miniredis.Miniredis) { + t.Helper() + mr, stop := newMiniredis(t) + t.Cleanup(stop) + + return &GuildRuntime{ + config: RuntimeConfig{UserID: "test-user", OrgID: "test-org"}, + redisClient: newRedisClient(t, mr), + ctx: context.Background(), + agentNames: make(map[string]string), + }, mr +} + +// newHTTPRuntime returns a GuildRuntime whose catalog/server base URLs point at +// an httptest.Server driven by handler. +func newHTTPRuntime(t *testing.T, handler http.Handler) *GuildRuntime { + t.Helper() + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + + return &GuildRuntime{ + config: RuntimeConfig{UserID: "test-user", OrgID: "test-org"}, + serverBase: ts.URL, + rusticBase: ts.URL, + ctx: context.Background(), + agentNames: make(map[string]string), + } +} + +// statusKey builds the redis key GetAgentStatuses reads: +// forge:agent:status::. +func statusKey(guildID, agentID string) string { + return "forge:agent:status:" + guildID + ":" + agentID +} + +// seedStatus writes value at key in mr, failing the test if the seed fails. +func seedStatus(t *testing.T, mr *miniredis.Miniredis, key, value string) { + t.Helper() + if err := mr.Set(key, value); err != nil { + t.Fatalf("seed %s: %v", key, err) + } +} diff --git a/forge-go/command/client.go b/forge-go/command/client.go index 87fe476..dd7726d 100644 --- a/forge-go/command/client.go +++ b/forge-go/command/client.go @@ -25,6 +25,7 @@ var ( clientDefaultSupervisor string clientDefaultTransport string clientZMQBridgeMode string + clientUVPython string ) func init() { @@ -40,6 +41,7 @@ func init() { ClientCmd.Flags().StringVar(&clientDefaultSupervisor, "default-supervisor", "", "Force a specific supervisor (docker, bwrap) for all agents on this node") ClientCmd.Flags().StringVar(&clientDefaultTransport, "default-agent-transport", "direct", `Default local agent dataplane transport (direct, supervisor-zmq)`) ClientCmd.Flags().StringVar(&clientZMQBridgeMode, "zmq-bridge-mode", "ipc", `ZMQ bridge transport for non-process supervisors: "ipc" or "tcp"`) + ClientCmd.Flags().StringVar(&clientUVPython, "uv-python", "", `Python interpreter to pin uv/uvx to when spawning Python agents (e.g. "3.13" or ">=3.13,<3.14"); empty lets uv choose`) RootCmd.AddCommand(ClientCmd) } @@ -53,6 +55,12 @@ var ClientCmd = &cobra.Command{ l := logging.NewLogger(out, logLevel) logging.SetGlobalLogger(l) + // Pin the interpreter uv/uvx uses for Python agents. registry.ResolveCommand + // (run in this node process) reads FORGE_UV_PYTHON. + if clientUVPython != "" { + _ = os.Setenv("FORGE_UV_PYTHON", clientUVPython) + } + if clientNodeID == "" { hostname, err := os.Hostname() if err != nil { diff --git a/forge-go/command/guild.go b/forge-go/command/guild.go new file mode 100644 index 0000000..3c8a9bd --- /dev/null +++ b/forge-go/command/guild.go @@ -0,0 +1,88 @@ +package command + +import ( + "github.com/spf13/cobra" +) + +var ( + guildBackend string + guildOrgID string + guildUserID string + guildUserName string + guildSupervisor string + guildPython string + guildUVPython string + guildVerbose bool + guildShowRouting bool + guildQuiet bool +) + +// GuildCmd is the parent command for guild-related operations +var GuildCmd = &cobra.Command{ + Use: "guild", + Short: "Guild testing and debugging commands", + Long: "Commands for running guilds locally for testing and debugging without the rustic-ui frontend", +} + +var guildRunCmd = &cobra.Command{ + Use: "run [guild-file]", + Short: "Run a guild locally with interactive chat", + Long: `Run a guild locally with interactive chat and message flow visualization. + +This command launches a guild from a YAML/JSON spec file and provides an interactive +REPL for chatting with the guild. You can see all messages flowing between agents, +routing decisions, and transformations in real-time. + +Example: + forge guild run examples/echo.yaml + forge guild run --verbose testdata/minimal.yaml + forge guild run --show-routing testdata/e2e/002_basic_message_routing.yaml`, + Args: cobra.ExactArgs(1), + RunE: runGuildREPL, +} + +var guildInspectCmd = &cobra.Command{ + Use: "inspect [guild-file]", + Short: "Parse and display guild specification", + Long: `Parse a guild spec file and display its structure. + +Shows agents, routing rules, dependencies, and other guild configuration +in a human-readable format. + +Example: + forge guild inspect examples/echo.yaml`, + Args: cobra.ExactArgs(1), + RunE: inspectGuild, +} + +var guildValidateCmd = &cobra.Command{ + Use: "validate [guild-file]", + Short: "Validate guild specification", + Long: `Validate a guild spec file for correctness. + +Checks for syntax errors, missing dependencies, invalid agent references, +and other common issues. + +Example: + forge guild validate examples/echo.yaml`, + Args: cobra.ExactArgs(1), + RunE: validateGuild, +} + +func init() { + GuildCmd.AddCommand(guildRunCmd) + GuildCmd.AddCommand(guildInspectCmd) + GuildCmd.AddCommand(guildValidateCmd) + + // Flags for 'run' command + guildRunCmd.Flags().StringVar(&guildBackend, "backend", "nats", "Messaging backend (redis|nats)") + guildRunCmd.Flags().StringVar(&guildOrgID, "org-id", "local-dev", "Organization ID") + guildRunCmd.Flags().StringVar(&guildUserID, "user-id", "test-user", "User ID") + guildRunCmd.Flags().StringVar(&guildUserName, "user-name", "Test User", "User name") + guildRunCmd.Flags().StringVar(&guildSupervisor, "supervisor", "process", "Supervisor type (process|docker|bubblewrap)") + guildRunCmd.Flags().StringVar(&guildPython, "python", "", "Python executable path (auto-detected if not specified)") + guildRunCmd.Flags().StringVar(&guildUVPython, "uv-python", "", `Python interpreter to pin uv/uvx to when spawning agents (e.g. "3.13" or ">=3.13,<3.14"); empty lets uv choose`) + guildRunCmd.Flags().BoolVarP(&guildVerbose, "verbose", "v", false, "Verbose output (show full message details)") + guildRunCmd.Flags().BoolVarP(&guildQuiet, "quiet", "q", false, "Quiet mode (minimal startup output)") + guildRunCmd.Flags().BoolVar(&guildShowRouting, "show-routing", true, "Show routing decisions and transformations") +} diff --git a/forge-go/command/guild_inspect.go b/forge-go/command/guild_inspect.go new file mode 100644 index 0000000..96a243f --- /dev/null +++ b/forge-go/command/guild_inspect.go @@ -0,0 +1,139 @@ +package command + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/rustic-ai/forge/forge-go/guild" + "github.com/rustic-ai/forge/forge-go/protocol" + "github.com/spf13/cobra" +) + +func inspectGuild(cmd *cobra.Command, args []string) error { + guildFile := args[0] + + fmt.Printf("📋 Inspecting guild spec: %s\n\n", guildFile) + + // Read file and check if it's a blueprint wrapper + content, err := os.ReadFile(guildFile) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + // Check if this is a blueprint wrapper (has a "spec" field). Best-effort JSON + // sniff: a parse failure leaves checker nil and falls through to the parser. + var checker map[string]any + _ = json.Unmarshal(content, &checker) + + var spec *protocol.GuildSpec + if specField, hasSpec := checker["spec"]; hasSpec && specField != nil { + // This is a blueprint wrapper - extract the nested spec + var wrapper struct { + Spec json.RawMessage `json:"spec"` + } + if err := json.Unmarshal(content, &wrapper); err != nil { + return fmt.Errorf("failed to parse wrapper: %w", err) + } + + var nestedSpec protocol.GuildSpec + if err := json.Unmarshal(wrapper.Spec, &nestedSpec); err != nil { + return fmt.Errorf("failed to parse nested spec: %w", err) + } + spec = &nestedSpec + } else { + // This is a direct guild spec + var err error + spec, _, err = guild.ParseFile(guildFile) + if err != nil { + return fmt.Errorf("failed to parse guild spec: %w", err) + } + } + + // Display basic info + fmt.Println("Guild Information:") + fmt.Printf(" Name: %s\n", spec.Name) + if spec.Description != "" { + fmt.Printf(" Description: %s\n", spec.Description) + } + if spec.ID != "" { + fmt.Printf(" ID: %s\n", spec.ID) + } + fmt.Println() + + // Display agents + fmt.Printf("Agents (%d):\n", len(spec.Agents)) + for i, agent := range spec.Agents { + fmt.Printf(" %d. %s (%s)\n", i+1, agent.Name, agent.ID) + fmt.Printf(" Class: %s\n", agent.ClassName) + if agent.Description != "" { + fmt.Printf(" Description: %s\n", agent.Description) + } + if len(agent.AdditionalTopics) > 0 { + fmt.Printf(" Additional Topics: %v\n", agent.AdditionalTopics) + } + fmt.Println() + } + + // Display routing rules + if spec.Routes != nil && len(spec.Routes.Steps) > 0 { + fmt.Printf("Routing Rules (%d):\n", len(spec.Routes.Steps)) + for i, rule := range spec.Routes.Steps { + fmt.Printf(" %d. ", i+1) + if rule.Agent != nil { + if rule.Agent.Name != nil && *rule.Agent.Name != "" { + fmt.Printf("Agent: %s", *rule.Agent.Name) + } else if rule.Agent.ID != nil && *rule.Agent.ID != "" { + fmt.Printf("Agent ID: %s", *rule.Agent.ID) + } + } + if rule.MethodName != nil && *rule.MethodName != "" { + fmt.Printf(" | Method: %s", *rule.MethodName) + } + fmt.Println() + + if rule.OriginFilter != nil { + if rule.OriginFilter.OriginMessageFormat != nil { + fmt.Printf(" Origin Format: %s\n", *rule.OriginFilter.OriginMessageFormat) + } + if rule.OriginFilter.OriginTopic != nil { + fmt.Printf(" Origin Topic: %s\n", *rule.OriginFilter.OriginTopic) + } + } + + if rule.Destination != nil { + if !rule.Destination.Topics.IsZero() { + fmt.Printf(" Destination Topics: %v\n", rule.Destination.Topics.ToSlice()) + } + if len(rule.Destination.RecipientList) > 0 { + fmt.Printf(" Recipients: %d agents\n", len(rule.Destination.RecipientList)) + } + } + + if rule.Transformer != nil { + fmt.Println(" Transformer: present") + } + + fmt.Println() + } + } + + // Display dependencies + if len(spec.DependencyMap) > 0 { + fmt.Printf("Dependencies (%d):\n", len(spec.DependencyMap)) + for name, dep := range spec.DependencyMap { + fmt.Printf(" - %s: %s\n", name, dep.ClassName) + } + fmt.Println() + } + + // Display properties + if len(spec.Properties) > 0 { + fmt.Println("Properties:") + propsJSON, _ := json.MarshalIndent(spec.Properties, " ", " ") + fmt.Printf(" %s\n", string(propsJSON)) + fmt.Println() + } + + return nil +} diff --git a/forge-go/command/guild_inspect_test.go b/forge-go/command/guild_inspect_test.go new file mode 100644 index 0000000..ba2f531 --- /dev/null +++ b/forge-go/command/guild_inspect_test.go @@ -0,0 +1,91 @@ +package command + +import ( + "strings" + "testing" +) + +func TestInspectGuild_Valid(t *testing.T) { + path := writeSpecFile(t, "guild.json", validGuildJSON) + var err error + out := captureStdout(t, func() { err = inspectGuild(nil, []string{path}) }) + if err != nil { + t.Fatalf("inspectGuild: %v", err) + } + // The guild name and the agents section should be printed. + if !strings.Contains(out, "Echo") { + t.Errorf("expected guild name in output, got %q", out) + } + if !strings.Contains(out, "Agents (1)") { + t.Errorf("expected agents section, got %q", out) + } + if !strings.Contains(out, "EchoAgent") { + t.Errorf("expected agent name in output, got %q", out) + } +} + +func TestInspectGuild_BlueprintWrapperUnwrapped(t *testing.T) { + path := writeSpecFile(t, "bp.json", blueprintJSON) + var err error + out := captureStdout(t, func() { err = inspectGuild(nil, []string{path}) }) + if err != nil { + t.Fatalf("inspectGuild: %v", err) + } + if !strings.Contains(out, "Inner") { + t.Errorf("expected unwrapped nested guild name 'Inner', got %q", out) + } +} + +// richGuildJSON exercises every section inspectGuild prints: description, id, +// agent description/topics, routing rule (agent, method, origin filter, +// destination topics + recipients, transformer), dependencies, and properties. +const richGuildJSON = `{ + "name":"Rich","description":"a rich guild","id":"guild-42", + "agents":[{"id":"a1","name":"AgentOne","class_name":"pkg.One","description":"the one","additional_topics":["topicX"]}], + "routes":{"steps":[{ + "agent":{"name":"AgentOne"}, + "method_name":"handle", + "origin_filter":{"origin_message_format":"fmtA","origin_topic":"topicO"}, + "destination":{"topics":["destT"],"recipient_list":[{"id":"r1"}]}, + "transformer":{"expression":"$"} + }]}, + "dependency_map":{"llm":{"class_name":"pkg.LLM"}}, + "properties":{"k":"v"} +}` + +func TestInspectGuild_AllSections(t *testing.T) { + path := writeSpecFile(t, "rich.json", richGuildJSON) + var err error + out := captureStdout(t, func() { err = inspectGuild(nil, []string{path}) }) + if err != nil { + t.Fatalf("inspectGuild: %v", err) + } + for _, want := range []string{ + "Description: a rich guild", "ID: guild-42", + "Additional Topics", "Routing Rules (1)", "Method: handle", + "Origin Format: fmtA", "Origin Topic: topicO", + "Destination Topics", "Recipients: 1 agents", "Transformer: present", + "Dependencies (1)", "Properties:", + } { + if !strings.Contains(out, want) { + t.Errorf("expected output to contain %q\n---\n%s", want, out) + } + } +} + +func TestInspectGuild_MissingFile(t *testing.T) { + var err error + captureStdout(t, func() { err = inspectGuild(nil, []string{"/no/such/guild.json"}) }) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestInspectGuild_UnsupportedExtension(t *testing.T) { + path := writeSpecFile(t, "guild.txt", "not a guild") + var err error + captureStdout(t, func() { err = inspectGuild(nil, []string{path}) }) + if err == nil { + t.Fatal("expected error for unsupported extension") + } +} diff --git a/forge-go/command/guild_run.go b/forge-go/command/guild_run.go new file mode 100644 index 0000000..c6206ee --- /dev/null +++ b/forge-go/command/guild_run.go @@ -0,0 +1,699 @@ +package command + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/rustic-ai/forge/forge-go/cli" + "github.com/rustic-ai/forge/forge-go/protocol" + "github.com/spf13/cobra" +) + +// guildRuntime is the subset of *cli.GuildRuntime that the display/publish +// helpers depend on. Defining it here lets the helpers be unit-tested with a +// fake; *cli.GuildRuntime satisfies it. +type guildRuntime interface { + GetAgentStatuses(guildID string) (map[string]cli.AgentStatus, error) + GetAgentName(agentID string) string + PublishMessage(namespace, topic string, msg *protocol.Message) error +} + +// messageSource is the subset of *cli.GuildSubscription that displayMessages +// consumes; *cli.GuildSubscription satisfies it. +type messageSource interface { + Messages() <-chan *protocol.Message + Errors() <-chan error +} + +func runGuildREPL(cmd *cobra.Command, args []string) error { + guildFile := args[0] + + // Get current working directory and forge root + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Find forge root (look for go.mod) + forgeRoot := findForgeRoot(cwd) + if forgeRoot == "" { + return fmt.Errorf("could not find forge root (no go.mod found)") + } + + // Auto-detect Python if not specified + pythonPath := guildPython + if pythonPath == "" { + pythonPath = detectPython() + } + + // Set up runtime config + // forgeRoot is already the forge-go directory + forgeRepoRoot := filepath.Dir(forgeRoot) // Parent is the repo root + config := cli.RuntimeConfig{ + Backend: guildBackend, + OrgID: guildOrgID, + UserID: guildUserID, + UserName: guildUserName, + ForgeRoot: forgeRepoRoot, + DependencyConfig: filepath.Join(forgeRoot, "conf", "agent-dependencies.yaml"), + AgentRegistry: filepath.Join(forgeRoot, "conf", "forge-agent-registry.yaml"), + ForgePythonPath: filepath.Join(forgeRepoRoot, "forge-python"), + SupervisorType: guildSupervisor, + PythonPath: pythonPath, + UVPython: guildUVPython, + } + + if !guildQuiet { + if pythonPath != "" { + fmt.Printf(" Python: %s\n", pythonPath) + } + + fmt.Println("🚀 Starting Forge Guild CLI...") + fmt.Printf(" Backend: %s\n", config.Backend) + fmt.Printf(" Guild: %s\n", guildFile) + fmt.Println() + } + + // Create and start runtime + runtime, err := cli.NewGuildRuntime(config) + if err != nil { + return fmt.Errorf("failed to create runtime: %w", err) + } + defer func() { _ = runtime.Shutdown() }() + + if !guildQuiet { + fmt.Println("⚙️ Starting embedded forge server...") + } + if err := runtime.Start(); err != nil { + return fmt.Errorf("failed to start runtime: %w", err) + } + + // Load guild spec + if !guildQuiet { + fmt.Printf("📋 Loading guild spec from %s...\n", guildFile) + } + spec, err := runtime.LoadGuild(guildFile) + if err != nil { + return fmt.Errorf("failed to load guild: %w", err) + } + + // Launch guild + if !guildQuiet { + fmt.Printf("🎯 Launching guild '%s'...\n", spec.Name) + } + guildID, err := runtime.LaunchGuild(spec) + if err != nil { + return fmt.Errorf("failed to launch guild: %w", err) + } + + if !guildQuiet { + fmt.Print("\n✅ Guild launched successfully!\n") + fmt.Printf(" Guild ID: %s\n", guildID) + fmt.Println() + } + + // Wait for the guild manager to finish initializing the guild before we + // create the UserProxyAgent or send any messages. Until the guild is + // initialized the manager rejects a UserProxyAgent creation request with + // "Guild is not initialized", and agent-process liveness is not a reliable + // signal (it is set at spawn, before initialization). Mirror rustic-ui and + // wait for the guild to report ready. + if !guildQuiet { + fmt.Println("⏳ Waiting for guild to initialize...") + } + if err := runtime.WaitForGuildReady(guildID, 2*time.Minute); err != nil { + fmt.Printf("⚠️ Warning: guild did not report ready: %v (continuing anyway)\n", err) + } + + // Determine the topic to send user messages to. + userMessageTopic, useWrappedMessages := selectUserMessageTopic(spec) + + // Show agent status + if !guildQuiet { + if err := showAgentStatus(os.Stdout, runtime, guildID); err != nil { + fmt.Printf("⚠️ Warning: could not get agent status: %v\n", err) + } + + fmt.Println("\n📡 Subscribing to message topics...") + } + + sub, err := runtime.Subscribe(guildID, config.UserID, spec) + if err != nil { + return fmt.Errorf("failed to subscribe: %w", err) + } + defer sub.Close() + + // Create UserProxyAgent only if this guild uses wrapped messages + if useWrappedMessages { + if !guildQuiet { + fmt.Println(" Creating UserProxyAgent...") + } + creationMsg, err := cli.BuildUserProxyCreationRequest(config.UserID, config.UserName) + if err != nil { + return fmt.Errorf("failed to build UserProxyAgent creation request: %w", err) + } + if err := runtime.PublishMessage(guildID, "system_topic", creationMsg); err != nil { + return fmt.Errorf("failed to create UserProxyAgent: %w", err) + } + + // Wait for UserProxyAgent to be created and appear in agent status + if !guildQuiet { + fmt.Println(" Waiting for UserProxyAgent to start...") + } + userProxyCreated := false + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + statuses, err := runtime.GetAgentStatuses(guildID) + if err == nil { + for agentID := range statuses { + agentName := runtime.GetAgentName(agentID) + if strings.Contains(agentName, config.UserID) || strings.Contains(agentID, "upa-") { + userProxyCreated = true + if guildVerbose { + fmt.Printf(" UserProxyAgent created: %s\n", agentName) + } + break + } + } + } + if userProxyCreated { + break + } + } + if !userProxyCreated && guildVerbose { + fmt.Println(" Warning: UserProxyAgent may not have been created") + } + } + + if guildVerbose { + if useWrappedMessages { + fmt.Printf(" Sending wrapped messages to: user:%s (routes to %s)\n\n", config.UserID, userMessageTopic) + } else { + fmt.Printf(" Sending user messages to: %s\n\n", userMessageTopic) + } + } + + // Set up context for shutdown + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + // Start message display goroutine + go displayMessages(ctx, os.Stdout, sub, runtime, config.UserID, guildVerbose, guildShowRouting) + + // Interactive REPL + fmt.Println("\n" + strings.Repeat("=", 70)) + fmt.Println("💬 Interactive Chat - Type your messages below") + fmt.Println(" Commands:") + fmt.Println(" /quit or /exit - Exit the REPL") + fmt.Println(" /status - Show agent status") + fmt.Println(" /help - Show this help") + fmt.Println(" Ctrl+C - Shutdown") + fmt.Println(strings.Repeat("=", 70)) + fmt.Println() + + // Create channel for stdin input + inputChan := make(chan string) + errChan := make(chan error) + + // Start goroutine to read from stdin + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + case inputChan <- scanner.Text(): + } + } + if err := scanner.Err(); err != nil { + errChan <- err + } + close(inputChan) + }() + + // Main REPL loop + for { + fmt.Print("> ") + + select { + case <-ctx.Done(): + fmt.Println("\n👋 Shutting down...") + return nil + + case err := <-errChan: + return fmt.Errorf("error reading input: %w", err) + + case line, ok := <-inputChan: + if !ok { + // Input channel closed (EOF) + fmt.Println("\n👋 Shutting down...") + return nil + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Handle commands + if strings.HasPrefix(line, "/") { + switch strings.ToLower(line) { + case "/quit", "/exit": + fmt.Println("👋 Goodbye!") + return nil + case "/status": + if err := showAgentStatus(os.Stdout, runtime, guildID); err != nil { + fmt.Printf("⚠️ Warning: could not get agent status: %v\n", err) + } + continue + case "/help": + fmt.Println("\nCommands:") + fmt.Println(" /quit, /exit - Exit the REPL") + fmt.Println(" /status - Show agent status") + fmt.Println(" /help - Show this help") + fmt.Println(" Ctrl+C - Shutdown") + fmt.Println() + continue + default: + fmt.Printf("Unknown command: %s\n", line) + continue + } + } + + // Send chat message + if err := sendChatMessage(os.Stdout, runtime, guildID, config.UserID, config.UserName, line, userMessageTopic, useWrappedMessages); err != nil { + fmt.Printf("❌ Error sending message: %v\n", err) + } + } + } +} + +func showAgentStatus(w io.Writer, runtime guildRuntime, guildID string) error { + statuses, err := runtime.GetAgentStatuses(guildID) + if err != nil { + return err + } + + fmt.Fprintln(w, "\n🤖 Agent Status:") + if len(statuses) == 0 { + fmt.Fprintln(w, " No agents found") + return nil + } + + for agentID, status := range statuses { + stateIcon := "●" + switch status.State { + case "running": + stateIcon = "🟢" + case "starting": + stateIcon = "🟡" + case "stopped": + stateIcon = "⚫" + case "error": + stateIcon = "🔴" + } + + // Get agent display name + agentName := runtime.GetAgentName(agentID) + if agentName != agentID { + fmt.Fprintf(w, " %s %s (PID: %d) - %s\n", stateIcon, agentName, status.PID, status.State) + } else { + fmt.Fprintf(w, " %s %s (PID: %d) - %s\n", stateIcon, agentID, status.PID, status.State) + } + } + fmt.Fprintln(w) + + return nil +} + +// selectUserMessageTopic decides which topic user input should be published to +// and whether messages must be wrapped for a UserProxyAgent. Guilds with a +// UserProxyAgent first route use wrapped messages; otherwise the first route's +// destination topic (or, with no routing, the first agent's additional topic) is +// used, falling back to "default_topic". +func selectUserMessageTopic(spec *protocol.GuildSpec) (topic string, useWrapped bool) { + topic = "default_topic" + + if spec.Routes != nil && len(spec.Routes.Steps) > 0 { + firstRoute := spec.Routes.Steps[0] + if firstRoute.AgentType != nil && *firstRoute.AgentType == "rustic_ai.core.agents.utils.user_proxy_agent.UserProxyAgent" { + // Messages go to user:{userID} and the UserProxyAgent routes them. + return topic, true + } + if firstRoute.Destination != nil { + if topics := firstRoute.Destination.Topics.ToSlice(); len(topics) > 0 { + return topics[0], false + } + } + return topic, false + } + + if len(spec.Agents) > 0 && len(spec.Agents[0].AdditionalTopics) > 0 { + return spec.Agents[0].AdditionalTopics[0], false + } + + return topic, false +} + +func sendChatMessage(w io.Writer, runtime guildRuntime, guildID, userID, userName, text, topic string, useWrapped bool) error { + var msg *protocol.Message + var err error + var actualTopic string + + if useWrapped { + // Build a wrapped message for UserProxyAgent + msg, err = cli.BuildWrappedChatMessage(userID, userName, text) + if err != nil { + return err + } + // Extract the actual topic from the message (should be user:{userID}) + actualTopic = msg.Topics.ToSlice()[0] + } else { + // Build a regular chat message + msg, err = cli.BuildChatMessage(userID, userName, text, topic) + if err != nil { + return err + } + actualTopic = topic + } + + fmt.Fprintf(w, "📤 Sending to topic: %s (format: %s)\n", actualTopic, msg.Format) + if err := runtime.PublishMessage(guildID, actualTopic, msg); err != nil { + return fmt.Errorf("failed to publish: %w", err) + } + + return nil +} + +func displayMessages(ctx context.Context, w io.Writer, sub messageSource, runtime guildRuntime, userID string, verbose, showRouting bool) { + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-sub.Messages(): + if !ok { + return + } + printMessage(w, msg, runtime, userID, verbose, showRouting) + case err, ok := <-sub.Errors(): + if !ok { + return + } + fmt.Fprintf(w, "\n❌ Subscription error: %v\n> ", err) + } + } +} + +func printMessage(w io.Writer, msg *protocol.Message, runtime guildRuntime, userID string, verbose, showRouting bool) { + // Debug: show all message formats in verbose mode only + if verbose { + topicsDebug := msg.Topics.ToSlice() + fmt.Fprintf(w, "\n[DEBUG] Format: %-50s Topics: %v\n", msg.Format, topicsDebug) + } + + // Skip internal system messages unless verbose + if !verbose { + // Skip health/heartbeat messages + if msg.Format == "healthReport" || msg.Format == "heartbeat" { + return + } + // Skip agent health reports + if msg.Format == "rustic_ai.core.guild.agent_ext.mixins.health.AgentsHealthReport" { + return + } + // Skip guild updated announcements (noisy during startup) + if msg.Format == "rustic_ai.core.agents.system.models.GuildUpdatedAnnouncement" { + return + } + // Skip state fetch responses + if msg.Format == "rustic_ai.core.state.models.StateFetchResponse" { + return + } + // Skip infra events unless they're errors + if msg.Format == "rustic_ai.forge.runtime.InfraEvent" { + return + } + // Skip participant list updates (noisy) + if msg.Format == "rustic_ai.core.agents.utils.user_proxy_agent.ParticipantList" { + return + } + } + + topics := msg.Topics.ToSlice() + + if !verbose { + // Skip messages on user:{userID} topic - these are echoes of our sent messages + if len(topics) > 0 && topics[0] == "user:"+userID { + return + } + + // For user_notifications: show only agent responses (3+ routing entries), skip unwrap notifications + if len(topics) > 0 && topics[0] == "user_notifications:"+userID && len(msg.MessageHistory) < 3 { + return + } + } + + // msg.Timestamp is milliseconds since the Unix epoch (derived from the + // GemstoneID clock, which uses UnixMilli), so decode it as milliseconds. + timestamp := time.UnixMilli(int64(msg.Timestamp)).Format("15:04:05") + topicStr := strings.Join(topics, ", ") + + // Message header + fmt.Fprintln(w, "\n"+strings.Repeat("─", 70)) + fmt.Fprintf(w, "📨 [%s] %s\n", timestamp, topicStr) + + // Get sender name - use agent name map if available + senderName := "" + senderID := "" + if msg.Sender.Name != nil && *msg.Sender.Name != "" { + senderName = *msg.Sender.Name + } + if msg.Sender.ID != nil { + senderID = *msg.Sender.ID + // Try to get a better name from the agent map + if agentName := runtime.GetAgentName(senderID); agentName != senderID { + senderName = agentName + } + } + + // Display sender - just the name, not the ID (cleaner) + if senderName != "" { + fmt.Fprintf(w, " From: %s\n", senderName) + } else if senderID != "" { + // Fallback to ID if no name + fmt.Fprintf(w, " From: %s\n", senderID) + } + + // Message content + if len(msg.Payload) > 0 { + var payload map[string]any + if err := json.Unmarshal(msg.Payload, &payload); err == nil { + // Pretty print payload + if verbose { + prettyPayload, _ := json.MarshalIndent(payload, " ", " ") + fmt.Fprintf(w, " Payload:\n %s\n", string(prettyPayload)) + } else { + // Show condensed version - try different message formats + displayed := false + + // Try chatCompletionRequest format (user messages) + if messages, ok := payload["messages"].([]any); ok && len(messages) > 0 { + if firstMsg, ok := messages[0].(map[string]any); ok { + if content, ok := firstMsg["content"].([]any); ok && len(content) > 0 { + if textContent, ok := content[0].(map[string]any); ok { + if text, ok := textContent["text"].(string); ok { + fmt.Fprintf(w, " 💬 %s\n", text) + displayed = true + } + } + } + } + } + + // Try chatCompletionResponse format (agent responses) + if !displayed { + if choices, ok := payload["choices"].([]any); ok && len(choices) > 0 { + if choice, ok := choices[0].(map[string]any); ok { + if message, ok := choice["message"].(map[string]any); ok { + if content, ok := message["content"].(string); ok { + fmt.Fprintf(w, " 💬 %s\n", content) + displayed = true + } + } + } + } + } + + // Try simple text field + if !displayed { + if text, ok := payload["text"].(string); ok { + fmt.Fprintf(w, " 💬 %s\n", text) + displayed = true + } + } + + // Try content field directly (common in some formats) + if !displayed { + if content, ok := payload["content"].(string); ok { + fmt.Fprintf(w, " 💬 %s\n", content) + displayed = true + } + } + + // Generic payload display if nothing matched + if !displayed { + payloadBytes, _ := json.Marshal(payload) + if len(payloadBytes) > 150 { + fmt.Fprintf(w, " 📄 %s...\n", string(payloadBytes[:150])) + } else { + fmt.Fprintf(w, " 📄 %s\n", string(payloadBytes)) + } + } + } + } + } + + // Routing information + if showRouting && len(msg.MessageHistory) > 0 { + fmt.Fprintln(w, " 🔀 Routing History:") + for i, entry := range msg.MessageHistory { + agent := "" + agentID := "" + if entry.Agent.Name != nil && *entry.Agent.Name != "" { + agent = *entry.Agent.Name + } + if entry.Agent.ID != nil { + agentID = *entry.Agent.ID + // Try to get better name from runtime + if betterName := runtime.GetAgentName(agentID); betterName != agentID { + agent = betterName + } else if agent == "" { + agent = agentID + } + } + if agent == "" { + agent = "unknown" + } + processor := entry.Processor + if processor == "" { + processor = "unknown" + } + + reasonStr := "" + if len(entry.Reason) > 0 { + reasonStr = fmt.Sprintf(" [%s]", strings.Join(entry.Reason, ", ")) + } + + fromTopic := "" + if entry.FromTopic != nil { + fromTopic = fmt.Sprintf(" from %s", *entry.FromTopic) + } + + toTopics := "" + if len(entry.ToTopics) > 0 { + toTopics = fmt.Sprintf(" → %s", strings.Join(entry.ToTopics, ", ")) + } + + fmt.Fprintf(w, " %d. %s (%s)%s%s%s\n", + i+1, agent, processor, fromTopic, toTopics, reasonStr) + } + } + + if verbose && msg.RoutingSlip != nil { + fmt.Fprintln(w, " 📋 Routing Slip:") + slipBytes, _ := json.MarshalIndent(msg.RoutingSlip, " ", " ") + fmt.Fprintf(w, " %s\n", string(slipBytes)) + } + + fmt.Fprint(w, "> ") +} + +func findForgeRoot(startDir string) string { + // First try current directory and walk up + dir := startDir + for { + goModPath := filepath.Join(dir, "go.mod") + if _, err := os.Stat(goModPath); err == nil { + // Check if this is the forge-go module + content, _ := os.ReadFile(goModPath) + if strings.Contains(string(content), "github.com/rustic-ai/forge/forge-go") { + // Return the forge-go directory itself + return dir + } + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + // If not found walking up, try common subdirectory patterns + // This handles the case where we're in the repo root but go.mod is in forge-go/ + commonSubdirs := []string{"forge-go", "go", "backend"} + for _, subdir := range commonSubdirs { + forgeGoPath := filepath.Join(startDir, subdir) + goModPath := filepath.Join(forgeGoPath, "go.mod") + if _, err := os.Stat(goModPath); err == nil { + content, _ := os.ReadFile(goModPath) + if strings.Contains(string(content), "github.com/rustic-ai/forge/forge-go") { + return forgeGoPath + } + } + } + + return "" +} + +func detectPython() string { + // Try pyenv which command first to get the real Python path (not shim) + cmd := exec.Command("pyenv", "which", "python") + if output, err := cmd.Output(); err == nil { + realPath := strings.TrimSpace(string(output)) + // Verify it's Python 3.13+ + versionCmd := exec.Command(realPath, "--version") + if versionOutput, err := versionCmd.Output(); err == nil { + version := strings.TrimSpace(string(versionOutput)) + if strings.Contains(version, "Python 3.13") || strings.Contains(version, "Python 3.14") { + return realPath + } + } + } + + // Try python from PATH (may be a shim) + if pythonPath, err := exec.LookPath("python"); err == nil { + cmd := exec.Command(pythonPath, "--version") + if output, err := cmd.Output(); err == nil { + version := strings.TrimSpace(string(output)) + if strings.Contains(version, "Python 3.13") || strings.Contains(version, "Python 3.14") { + return pythonPath + } + } + } + + // Try python3 as fallback + if pythonPath, err := exec.LookPath("python3"); err == nil { + cmd := exec.Command(pythonPath, "--version") + if output, err := cmd.Output(); err == nil { + version := strings.TrimSpace(string(output)) + if strings.Contains(version, "Python 3.13") || strings.Contains(version, "Python 3.14") { + return pythonPath + } + } + // Return python3 even if not 3.13, better than nothing + return pythonPath + } + + return "" +} diff --git a/forge-go/command/guild_run_helpers_test.go b/forge-go/command/guild_run_helpers_test.go new file mode 100644 index 0000000..79fd40b --- /dev/null +++ b/forge-go/command/guild_run_helpers_test.go @@ -0,0 +1,233 @@ +package command + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rustic-ai/forge/forge-go/cli" + "github.com/rustic-ai/forge/forge-go/protocol" +) + +func TestSelectUserMessageTopic(t *testing.T) { + userProxy := "rustic_ai.core.agents.utils.user_proxy_agent.UserProxyAgent" + + dest := protocol.NewRoutingDestination() + dest.Topics = protocol.TopicsFromString("dest_topic") + + cases := []struct { + name string + spec *protocol.GuildSpec + wantTopic string + wantWrapped bool + }{ + { + name: "user proxy uses wrapped messages", + spec: &protocol.GuildSpec{Routes: &protocol.RoutingSlip{Steps: []protocol.RoutingRule{{AgentType: &userProxy}}}}, + wantTopic: "default_topic", + wantWrapped: true, + }, + { + name: "route destination topic", + spec: &protocol.GuildSpec{Routes: &protocol.RoutingSlip{Steps: []protocol.RoutingRule{{Destination: &dest}}}}, + wantTopic: "dest_topic", + wantWrapped: false, + }, + { + name: "no routing falls back to first agent additional topic", + spec: &protocol.GuildSpec{Agents: []protocol.AgentSpec{{AdditionalTopics: []string{"agent_topic"}}}}, + wantTopic: "agent_topic", + wantWrapped: false, + }, + { + name: "default when nothing configured", + spec: &protocol.GuildSpec{}, + wantTopic: "default_topic", + wantWrapped: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + topic, wrapped := selectUserMessageTopic(tc.spec) + if topic != tc.wantTopic || wrapped != tc.wantWrapped { + t.Errorf("got (%q, %v), want (%q, %v)", topic, wrapped, tc.wantTopic, tc.wantWrapped) + } + }) + } +} + +func TestDetectPython(t *testing.T) { + dir := t.TempDir() + script := "#!/bin/sh\necho \"Python 3.13.0\"\n" + py3 := filepath.Join(dir, "python3") + if err := os.WriteFile(py3, []byte(script), 0o755); err != nil { + t.Fatalf("write fake python3: %v", err) + } + // Isolate PATH so only our fake python3 is discoverable (no pyenv/python). + t.Setenv("PATH", dir) + + if got := detectPython(); got != py3 { + t.Errorf("detectPython() = %q, want %q", got, py3) + } +} + +func TestDetectPython_NoneFound(t *testing.T) { + t.Setenv("PATH", t.TempDir()) // empty dir on PATH -> nothing found + if got := detectPython(); got != "" { + t.Errorf("detectPython() = %q, want empty string", got) + } +} + +func TestDetectPython_PythonBranch(t *testing.T) { + dir := t.TempDir() + py := filepath.Join(dir, "python") + if err := os.WriteFile(py, []byte("#!/bin/sh\necho \"Python 3.14.1\"\n"), 0o755); err != nil { + t.Fatalf("write fake python: %v", err) + } + t.Setenv("PATH", dir) + if got := detectPython(); got != py { + t.Errorf("detectPython() = %q, want %q (python branch)", got, py) + } +} + +func TestDetectPython_Python3WrongVersionFallback(t *testing.T) { + dir := t.TempDir() + py3 := filepath.Join(dir, "python3") + // An older python3 is still returned as a best-effort fallback. + if err := os.WriteFile(py3, []byte("#!/bin/sh\necho \"Python 3.10.0\"\n"), 0o755); err != nil { + t.Fatalf("write fake python3: %v", err) + } + t.Setenv("PATH", dir) + if got := detectPython(); got != py3 { + t.Errorf("detectPython() = %q, want %q (fallback)", got, py3) + } +} + +func TestRunGuildREPL_NoForgeRoot(t *testing.T) { + // From a directory with no go.mod, findForgeRoot returns "" and runGuildREPL + // should fail fast before touching any runtime. + t.Chdir(t.TempDir()) + err := runGuildREPL(nil, []string{"missing.json"}) + if err == nil || !strings.Contains(err.Error(), "forge root") { + t.Errorf("expected 'forge root' error, got %v", err) + } +} + +func TestShowAgentStatus(t *testing.T) { + rt := &fakeRuntime{ + statuses: map[string]cli.AgentStatus{ + "a1": {AgentID: "a1", State: "running", PID: 100}, + }, + names: map[string]string{"a1": "EchoAgent"}, + } + var buf bytes.Buffer + if err := showAgentStatus(&buf, rt, "g1"); err != nil { + t.Fatalf("showAgentStatus: %v", err) + } + out := buf.String() + if !strings.Contains(out, "EchoAgent") || !strings.Contains(out, "100") || !strings.Contains(out, "running") { + t.Errorf("expected agent line with name/pid/state, got %q", out) + } +} + +func TestShowAgentStatus_Empty(t *testing.T) { + var buf bytes.Buffer + if err := showAgentStatus(&buf, &fakeRuntime{statuses: map[string]cli.AgentStatus{}}, "g1"); err != nil { + t.Fatalf("showAgentStatus: %v", err) + } + if !strings.Contains(buf.String(), "No agents found") { + t.Errorf("expected 'No agents found', got %q", buf.String()) + } +} + +func TestShowAgentStatus_Error(t *testing.T) { + rt := &fakeRuntime{statusErr: context.DeadlineExceeded} + if err := showAgentStatus(&bytes.Buffer{}, rt, "g1"); err == nil { + t.Error("expected error propagated from GetAgentStatuses") + } +} + +func TestSendChatMessage_Regular(t *testing.T) { + rt := &fakeRuntime{} + var buf bytes.Buffer + if err := sendChatMessage(&buf, rt, "g1", "alice", "Alice", "hello", "some_topic", false); err != nil { + t.Fatalf("sendChatMessage: %v", err) + } + if len(rt.published) != 1 { + t.Fatalf("expected 1 published message, got %d", len(rt.published)) + } + if rt.published[0].topic != "some_topic" { + t.Errorf("published topic = %q, want some_topic", rt.published[0].topic) + } + if !strings.Contains(buf.String(), "Sending to topic: some_topic") { + t.Errorf("expected send banner, got %q", buf.String()) + } +} + +func TestSendChatMessage_Wrapped(t *testing.T) { + rt := &fakeRuntime{} + if err := sendChatMessage(&bytes.Buffer{}, rt, "g1", "alice", "Alice", "hi", "ignored", true); err != nil { + t.Fatalf("sendChatMessage: %v", err) + } + if len(rt.published) != 1 || rt.published[0].topic != "user:alice" { + t.Errorf("wrapped message should publish to user:alice, got %+v", rt.published) + } +} + +func TestSendChatMessage_PublishError(t *testing.T) { + rt := &fakeRuntime{publishErr: context.DeadlineExceeded} + if err := sendChatMessage(&bytes.Buffer{}, rt, "g1", "alice", "Alice", "hi", "t", false); err == nil { + t.Error("expected publish error to propagate") + } +} + +func TestDisplayMessages_PrintsAndExitsOnClose(t *testing.T) { + rt := &fakeRuntime{} + src := newFakeSource() + src.msgs <- msgWith("x", "default_topic", `{"text":"hello"}`) + // Close only msgs so the loop deterministically prints the buffered message + // and then returns on the closed channel (errs stays open+empty, never ready). + close(src.msgs) + + var buf bytes.Buffer + displayMessages(context.Background(), &buf, src, rt, "alice", false, false) + + if !strings.Contains(buf.String(), "hello") { + t.Errorf("expected message content in output, got %q", buf.String()) + } +} + +func TestDisplayMessages_ReportsErrors(t *testing.T) { + src := newFakeSource() + src.errs <- context.DeadlineExceeded + // Close only errs; msgs stays open+empty so the loop deterministically + // prints the error then returns on the closed errs channel. + close(src.errs) + + var buf bytes.Buffer + displayMessages(context.Background(), &buf, src, &fakeRuntime{}, "alice", false, false) + + if !strings.Contains(buf.String(), "Subscription error") { + t.Errorf("expected subscription error in output, got %q", buf.String()) + } +} + +func TestDisplayMessages_StopsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + // Channels stay open; the cancelled context must make displayMessages return. + done := make(chan struct{}) + go func() { + displayMessages(ctx, &bytes.Buffer{}, newFakeSource(), &fakeRuntime{}, "alice", false, false) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("displayMessages did not return on context cancel") + } +} diff --git a/forge-go/command/guild_run_test.go b/forge-go/command/guild_run_test.go new file mode 100644 index 0000000..740d9d5 --- /dev/null +++ b/forge-go/command/guild_run_test.go @@ -0,0 +1,58 @@ +package command + +import ( + "os" + "path/filepath" + "testing" +) + +const forgeGoModule = "module github.com/rustic-ai/forge/forge-go\n\ngo 1.25.0\n" + +func mkGoMod(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(content), 0o600); err != nil { + t.Fatalf("write go.mod: %v", err) + } +} + +func TestFindForgeRoot_WalksUpToModule(t *testing.T) { + root := t.TempDir() + mkGoMod(t, root, forgeGoModule) + nested := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + if got := findForgeRoot(nested); got != root { + t.Fatalf("expected forge root %q, got %q", root, got) + } +} + +func TestFindForgeRoot_FindsSubdirFromRepoRoot(t *testing.T) { + base := t.TempDir() + forgeGo := filepath.Join(base, "forge-go") + mkGoMod(t, forgeGo, forgeGoModule) + + if got := findForgeRoot(base); got != forgeGo { + t.Fatalf("expected forge-go subdir %q, got %q", forgeGo, got) + } +} + +func TestFindForgeRoot_IgnoresUnrelatedModule(t *testing.T) { + root := t.TempDir() + // A go.mod that is not the forge-go module must not be treated as the root. + mkGoMod(t, root, "module example.com/other\n\ngo 1.25.0\n") + + if got := findForgeRoot(root); got != "" { + t.Fatalf("expected empty result for unrelated module, got %q", got) + } +} + +func TestFindForgeRoot_NotFound(t *testing.T) { + if got := findForgeRoot(t.TempDir()); got != "" { + t.Fatalf("expected empty result when no go.mod exists, got %q", got) + } +} diff --git a/forge-go/command/guild_validate.go b/forge-go/command/guild_validate.go new file mode 100644 index 0000000..f5e35d8 --- /dev/null +++ b/forge-go/command/guild_validate.go @@ -0,0 +1,114 @@ +package command + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/rustic-ai/forge/forge-go/guild" + "github.com/rustic-ai/forge/forge-go/protocol" + "github.com/spf13/cobra" +) + +func validateGuild(cmd *cobra.Command, args []string) error { + guildFile := args[0] + + fmt.Printf("🔍 Validating guild spec: %s\n\n", guildFile) + + // Read file and check if it's a blueprint wrapper + content, err := os.ReadFile(guildFile) + if err != nil { + fmt.Printf("❌ Validation FAILED: %v\n", err) + return err + } + + // Check if this is a blueprint wrapper (has a "spec" field). Best-effort JSON + // sniff: a parse failure leaves checker nil and falls through to the parser. + var checker map[string]any + _ = json.Unmarshal(content, &checker) + + var spec *protocol.GuildSpec + if specField, hasSpec := checker["spec"]; hasSpec && specField != nil { + // This is a blueprint wrapper - extract the nested spec + var wrapper struct { + Spec json.RawMessage `json:"spec"` + } + if err := json.Unmarshal(content, &wrapper); err != nil { + fmt.Printf("❌ Validation FAILED: %v\n", err) + return err + } + + var nestedSpec protocol.GuildSpec + if err := json.Unmarshal(wrapper.Spec, &nestedSpec); err != nil { + fmt.Printf("❌ Validation FAILED: %v\n", err) + return err + } + spec = &nestedSpec + } else { + // This is a direct guild spec + var err error + spec, _, err = guild.ParseFile(guildFile) + if err != nil { + fmt.Printf("❌ Validation FAILED: %v\n", err) + return err + } + } + + errors := []string{} + warnings := []string{} + + // Validate basic fields + if spec.Name == "" { + errors = append(errors, "Guild name is required") + } + + if len(spec.Agents) == 0 { + errors = append(errors, "Guild must have at least one agent") + } + + // Validate agents + for i, agent := range spec.Agents { + if agent.ClassName == "" { + errors = append(errors, fmt.Sprintf("Agent %d (%s) missing class_name", i+1, agent.Name)) + } + if agent.ID == "" { + warnings = append(warnings, fmt.Sprintf("Agent %d (%s) has no ID", i+1, agent.Name)) + } + } + + // Validate routing rules + if spec.Routes != nil { + for i, rule := range spec.Routes.Steps { + if rule.Agent == nil && rule.AgentType == nil { + warnings = append(warnings, fmt.Sprintf("Routing rule %d has no agent or agent_type specified", i+1)) + } + } + } + + // Display results + if len(errors) > 0 { + fmt.Println("❌ Validation Errors:") + for _, err := range errors { + fmt.Printf(" • %s\n", err) + } + fmt.Println() + } + + if len(warnings) > 0 { + fmt.Println("⚠️ Validation Warnings:") + for _, warn := range warnings { + fmt.Printf(" • %s\n", warn) + } + fmt.Println() + } + + if len(errors) == 0 { + fmt.Println("✅ Validation PASSED") + if len(warnings) > 0 { + fmt.Printf(" (with %d warnings)\n", len(warnings)) + } + return nil + } + + return fmt.Errorf("validation failed with %d errors", len(errors)) +} diff --git a/forge-go/command/guild_validate_test.go b/forge-go/command/guild_validate_test.go new file mode 100644 index 0000000..59d1482 --- /dev/null +++ b/forge-go/command/guild_validate_test.go @@ -0,0 +1,109 @@ +package command + +import ( + "strings" + "testing" +) + +func TestValidateGuild_Valid(t *testing.T) { + path := writeSpecFile(t, "guild.json", validGuildJSON) + var err error + out := captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err != nil { + t.Fatalf("expected valid spec, got %v", err) + } + if !strings.Contains(out, "PASSED") { + t.Errorf("expected PASSED in output, got %q", out) + } +} + +func TestValidateGuild_MissingName(t *testing.T) { + path := writeSpecFile(t, "guild.json", + `{"name":"","agents":[{"id":"a1","name":"A","class_name":"pkg.A"}]}`) + var err error + out := captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err == nil { + t.Fatal("expected validation error for missing name") + } + if !strings.Contains(out, "Guild name is required") { + t.Errorf("expected name error, got %q", out) + } +} + +func TestValidateGuild_NoAgents(t *testing.T) { + path := writeSpecFile(t, "guild.json", `{"name":"G","agents":[]}`) + var err error + out := captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err == nil { + t.Fatal("expected validation error for zero agents") + } + if !strings.Contains(out, "at least one agent") { + t.Errorf("expected agent-count error, got %q", out) + } +} + +func TestValidateGuild_AgentMissingClassName(t *testing.T) { + path := writeSpecFile(t, "guild.json", + `{"name":"G","agents":[{"id":"a1","name":"A","class_name":""}]}`) + var err error + out := captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err == nil { + t.Fatal("expected error for missing class_name") + } + if !strings.Contains(out, "class_name") { + t.Errorf("expected class_name error, got %q", out) + } +} + +func TestValidateGuild_AgentMissingIDIsWarning(t *testing.T) { + path := writeSpecFile(t, "guild.json", + `{"name":"G","agents":[{"id":"","name":"A","class_name":"pkg.A"}]}`) + var err error + out := captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err != nil { + t.Fatalf("missing id should be a warning, not an error; got %v", err) + } + if !strings.Contains(out, "PASSED") || !strings.Contains(strings.ToLower(out), "warning") { + t.Errorf("expected PASSED with a warning, got %q", out) + } +} + +func TestValidateGuild_BlueprintWrapper(t *testing.T) { + path := writeSpecFile(t, "bp.json", blueprintJSON) + var err error + captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err != nil { + t.Fatalf("expected blueprint wrapper to validate the nested spec, got %v", err) + } +} + +func TestValidateGuild_RouteWithoutAgentIsWarning(t *testing.T) { + // A route step with neither agent nor agent_type is a warning, not an error. + path := writeSpecFile(t, "guild.json", + `{"name":"G","agents":[{"id":"a1","name":"A","class_name":"pkg.A"}],"routes":{"steps":[{}]}}`) + var err error + out := captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err != nil { + t.Fatalf("route warning should not fail validation, got %v", err) + } + if !strings.Contains(strings.ToLower(out), "warning") { + t.Errorf("expected a warning about the route, got %q", out) + } +} + +func TestValidateGuild_MissingFile(t *testing.T) { + var err error + captureStdout(t, func() { err = validateGuild(nil, []string{"/no/such/guild.json"}) }) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestValidateGuild_MalformedNestedSpec(t *testing.T) { + path := writeSpecFile(t, "bp.json", `{"name":"BP","spec":"not-an-object"}`) + var err error + captureStdout(t, func() { err = validateGuild(nil, []string{path}) }) + if err == nil { + t.Fatal("expected error for malformed nested spec") + } +} diff --git a/forge-go/command/printmessage_test.go b/forge-go/command/printmessage_test.go new file mode 100644 index 0000000..5cb8a75 --- /dev/null +++ b/forge-go/command/printmessage_test.go @@ -0,0 +1,134 @@ +package command + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/rustic-ai/forge/forge-go/protocol" +) + +func strptr(s string) *string { return &s } + +func msgWith(format, topic, payload string) *protocol.Message { + m := &protocol.Message{Format: format, Topics: protocol.TopicsFromString(topic)} + if payload != "" { + m.Payload = json.RawMessage(payload) + } + return m +} + +func render(msg *protocol.Message, rt guildRuntime, userID string, verbose, showRouting bool) string { + var buf bytes.Buffer + printMessage(&buf, msg, rt, userID, verbose, showRouting) + return buf.String() +} + +func TestPrintMessage_SkipsNoisyFormats(t *testing.T) { + rt := &fakeRuntime{} + skip := []string{ + "healthReport", + "heartbeat", + "rustic_ai.core.guild.agent_ext.mixins.health.AgentsHealthReport", + "rustic_ai.core.agents.system.models.GuildUpdatedAnnouncement", + "rustic_ai.core.state.models.StateFetchResponse", + "rustic_ai.forge.runtime.InfraEvent", + "rustic_ai.core.agents.utils.user_proxy_agent.ParticipantList", + } + for _, format := range skip { + out := render(msgWith(format, "default_topic", ""), rt, "alice", false, false) + if out != "" { + t.Errorf("format %q should be hidden, got output: %q", format, out) + } + } +} + +func TestPrintMessage_SkipsOwnEchoesAndUnwrapNotifications(t *testing.T) { + rt := &fakeRuntime{} + + // Echo of our own message on user:{userID}. + if out := render(msgWith("x", "user:alice", ""), rt, "alice", false, false); out != "" { + t.Errorf("user:alice echo should be hidden, got %q", out) + } + + // user_notifications with <3 routing entries is an unwrap notification. + m := msgWith("x", "user_notifications:alice", "") + if out := render(m, rt, "alice", false, false); out != "" { + t.Errorf("short user_notifications should be hidden, got %q", out) + } +} + +func TestPrintMessage_PayloadFormats(t *testing.T) { + rt := &fakeRuntime{} + cases := []struct { + name string + payload string + want string + }{ + {"chatCompletionRequest", `{"messages":[{"role":"user","content":[{"type":"text","text":"hello there"}]}]}`, "hello there"}, + {"chatCompletionResponse", `{"choices":[{"message":{"content":"agent reply"}}]}`, "agent reply"}, + {"textField", `{"text":"plain text"}`, "plain text"}, + {"contentField", `{"content":"raw content"}`, "raw content"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := render(msgWith("x", "default_topic", tc.payload), rt, "alice", false, false) + if !strings.Contains(out, "💬") || !strings.Contains(out, tc.want) { + t.Errorf("expected 💬 %q, got %q", tc.want, out) + } + }) + } +} + +func TestPrintMessage_GenericPayloadTruncated(t *testing.T) { + rt := &fakeRuntime{} + long := strings.Repeat("a", 300) + out := render(msgWith("x", "default_topic", `{"blob":"`+long+`"}`), rt, "alice", false, false) + if !strings.Contains(out, "📄") || !strings.Contains(out, "...") { + t.Errorf("expected truncated generic payload with 📄 and ..., got %q", out) + } +} + +func TestPrintMessage_SenderName(t *testing.T) { + rt := &fakeRuntime{names: map[string]string{"a1": "Friendly Agent"}} + + // Explicit sender name wins. + m := msgWith("x", "default_topic", `{"text":"hi"}`) + m.Sender = protocol.AgentTag{Name: strptr("Agent Smith")} + if out := render(m, rt, "alice", false, false); !strings.Contains(out, "From: Agent Smith") { + t.Errorf("expected sender name, got %q", out) + } + + // Name resolved from the runtime map by ID. + m2 := msgWith("x", "default_topic", `{"text":"hi"}`) + m2.Sender = protocol.AgentTag{ID: strptr("a1")} + if out := render(m2, rt, "alice", false, false); !strings.Contains(out, "From: Friendly Agent") { + t.Errorf("expected resolved name, got %q", out) + } +} + +func TestPrintMessage_VerboseShowsSkippedAndPayload(t *testing.T) { + rt := &fakeRuntime{} + // A normally-skipped format is shown in verbose mode, with a DEBUG line and + // pretty-printed payload. + out := render(msgWith("healthReport", "default_topic", `{"k":"v"}`), rt, "alice", true, false) + if !strings.Contains(out, "[DEBUG]") { + t.Errorf("verbose should print DEBUG header, got %q", out) + } + if !strings.Contains(out, "Payload:") { + t.Errorf("verbose should pretty-print payload, got %q", out) + } +} + +func TestPrintMessage_RoutingHistory(t *testing.T) { + rt := &fakeRuntime{} + m := msgWith("x", "default_topic", `{"text":"hi"}`) + m.MessageHistory = []protocol.ProcessEntry{ + {Agent: protocol.AgentTag{Name: strptr("RouterA")}, Processor: "route", ToTopics: []string{"t2"}}, + } + out := render(m, rt, "alice", false, true) + if !strings.Contains(out, "🔀 Routing History:") || !strings.Contains(out, "RouterA") || !strings.Contains(out, "route") { + t.Errorf("expected routing history with RouterA/route, got %q", out) + } +} diff --git a/forge-go/command/root.go b/forge-go/command/root.go index da9e346..ddc69b1 100644 --- a/forge-go/command/root.go +++ b/forge-go/command/root.go @@ -33,4 +33,7 @@ func init() { forgepath.SetHome(forgeHome) } } + + // Register guild command + RootCmd.AddCommand(GuildCmd) } diff --git a/forge-go/command/server.go b/forge-go/command/server.go index fe0b09b..ef312c4 100644 --- a/forge-go/command/server.go +++ b/forge-go/command/server.go @@ -33,6 +33,7 @@ var ( serverClientZMQBridgeMode string serverBackend string serverEmbeddedNATSAddr string + serverUVPython string serverStateStore string serverTelemetryEnabled bool serverTelemetryMode string @@ -65,6 +66,7 @@ func init() { ServerCmd.Flags().StringVar(&serverClientZMQBridgeMode, "client-zmq-bridge-mode", "ipc", `ZMQ bridge transport for non-process supervisors: "ipc" or "tcp"`) ServerCmd.Flags().StringVar(&serverBackend, "backend", "redis", `Messaging backend: "redis" or "nats"`) ServerCmd.Flags().StringVar(&serverEmbeddedNATSAddr, "embedded-nats-addr", "", "Bind address for embedded NATS (default: ephemeral port)") + ServerCmd.Flags().StringVar(&serverUVPython, "uv-python", "", `Python interpreter to pin uv/uvx to when spawning Python agents (e.g. "3.13" or ">=3.13,<3.14"); empty lets uv choose`) ServerCmd.Flags().StringVar(&serverStateStore, "state-store", "", `State store backend: "diskcache" (default: in-memory)`) ServerCmd.Flags().BoolVar(&serverTelemetryEnabled, "otel-enabled", false, "Enable OpenTelemetry export from Forge server") ServerCmd.Flags().StringVar(&serverTelemetryMode, "otel-mode", "desktop_sqlite", `Telemetry backend mode: "desktop_sqlite" or "external_otlp"`) @@ -87,6 +89,12 @@ var ServerCmd = &cobra.Command{ l := logging.NewLogger(out, logLevel) logging.SetGlobalLogger(l) + // Pin the interpreter uv/uvx uses for Python agents. registry.ResolveCommand + // (run in this process, incl. the in-process client) reads FORGE_UV_PYTHON. + if serverUVPython != "" { + _ = os.Setenv("FORGE_UV_PYTHON", serverUVPython) + } + db := serverDB if db == "" { db = "sqlite://" + forgepath.Resolve("data/forge.db") diff --git a/forge-go/command/testhelpers_test.go b/forge-go/command/testhelpers_test.go new file mode 100644 index 0000000..81a73a8 --- /dev/null +++ b/forge-go/command/testhelpers_test.go @@ -0,0 +1,97 @@ +package command + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/rustic-ai/forge/forge-go/cli" + "github.com/rustic-ai/forge/forge-go/protocol" +) + +// captureStdout redirects os.Stdout for the duration of fn and returns whatever +// was written. inspectGuild/validateGuild print via fmt.Print* to os.Stdout +// directly, so this is the only way to assert their output. Tests using it must +// not run in parallel (os.Stdout is process-global). +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = old }() + + fn() + _ = w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +// publishedMessage records a PublishMessage call for assertions. +type publishedMessage struct { + namespace string + topic string + msg *protocol.Message +} + +// fakeRuntime is a test double for the guildRuntime interface. +type fakeRuntime struct { + statuses map[string]cli.AgentStatus + statusErr error + names map[string]string + published []publishedMessage + publishErr error +} + +func (f *fakeRuntime) GetAgentStatuses(string) (map[string]cli.AgentStatus, error) { + return f.statuses, f.statusErr +} + +func (f *fakeRuntime) GetAgentName(agentID string) string { + if n, ok := f.names[agentID]; ok { + return n + } + return agentID +} + +func (f *fakeRuntime) PublishMessage(namespace, topic string, msg *protocol.Message) error { + f.published = append(f.published, publishedMessage{namespace, topic, msg}) + return f.publishErr +} + +// fakeSource is a test double for the messageSource interface. +type fakeSource struct { + msgs chan *protocol.Message + errs chan error +} + +func newFakeSource() *fakeSource { + return &fakeSource{ + msgs: make(chan *protocol.Message, 8), + errs: make(chan error, 8), + } +} + +func (f *fakeSource) Messages() <-chan *protocol.Message { return f.msgs } +func (f *fakeSource) Errors() <-chan error { return f.errs } + +// writeSpecFile writes content to a file with the given name under a temp dir. +func writeSpecFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write spec file: %v", err) + } + return path +} + +// minimal valid guild specs reused across command tests. +const ( + validGuildJSON = `{"name":"Echo","description":"d","agents":[{"id":"a1","name":"EchoAgent","class_name":"pkg.Echo"}]}` + blueprintJSON = `{"name":"BP","spec":{"name":"Inner","description":"d","agents":[{"id":"a1","name":"EchoAgent","class_name":"pkg.Echo"}]}}` +) diff --git a/forge-go/conf/agent-dependencies.yaml b/forge-go/conf/agent-dependencies.yaml index 413344e..6b2be63 100644 --- a/forge-go/conf/agent-dependencies.yaml +++ b/forge-go/conf/agent-dependencies.yaml @@ -33,6 +33,11 @@ kb_backend: # --------------------------------------------------------------------------- # LLM Dependencies # --------------------------------------------------------------------------- +# NOTE: LiteLLMResolver.__init__(model, conf={}) accepts only `model` plus a +# `conf` dict; all other connection settings (base_url, api_version, +# vertex_project, custom_llm_provider, ...) must be nested under `conf:` or the +# resolver raises TypeError on instantiation. This mirrors the `textsplitter` +# entry above. # --- Local llama.cpp server (OpenAI-compatible, port 55262) --- @@ -41,49 +46,56 @@ llm: provided_type: rustic_ai.core.llm.LLM properties: model: openai/rustic/qwen3.5-0.8b-starter - base_url: http://localhost:55262/v1 + conf: + base_url: http://localhost:55262/v1 llm_local_qwen3_5_0_8b: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver provided_type: rustic_ai.core.llm.LLM properties: model: openai/rustic/qwen3.5-0.8b-starter - base_url: http://localhost:55262/v1 + conf: + base_url: http://localhost:55262/v1 llm_local_nomic_embed: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver provided_type: rustic_ai.core.llm.LLM properties: model: openai/rustic/nomic-embed-default - base_url: http://localhost:55262/v1 + conf: + base_url: http://localhost:55262/v1 llm_local_qwen3_5_2b: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver provided_type: rustic_ai.core.llm.LLM properties: model: openai/rustic/qwen3.5-2b-balanced - base_url: http://localhost:55262/v1 + conf: + base_url: http://localhost:55262/v1 llm_local_qwen3_5_4b: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver provided_type: rustic_ai.core.llm.LLM properties: model: openai/rustic/qwen3.5-4b-quality - base_url: http://localhost:55262/v1 + conf: + base_url: http://localhost:55262/v1 llm_local_qwen3_4b_text: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver provided_type: rustic_ai.core.llm.LLM properties: model: openai/rustic/qwen3-4b-text - base_url: http://localhost:55262/v1 + conf: + base_url: http://localhost:55262/v1 llm_local_gemma3_4b: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver provided_type: rustic_ai.core.llm.LLM properties: model: openai/rustic/gemma-3-4b-it - base_url: http://localhost:55262/v1 + conf: + base_url: http://localhost:55262/v1 # --- OpenAI --- # Requires: OPENAI_API_KEY @@ -123,8 +135,9 @@ llm_vertexai: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver properties: model: vertex_ai/gemini-3.1 - vertex_project: null - vertex_location: null + conf: + vertex_project: null + vertex_location: null # --- AWS Bedrock --- # Requires: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION_NAME @@ -133,7 +146,8 @@ llm_bedrock: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver properties: model: bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 - custom_llm_provider: bedrock + conf: + custom_llm_provider: bedrock # --- Azure OpenAI --- # Requires: AZURE_API_KEY, AZURE_API_BASE, AZURE_API_VERSION @@ -142,8 +156,9 @@ llm_azure: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver properties: model: azure/ - base_url: null - api_version: "2024-06-01" + conf: + base_url: null + api_version: "2024-06-01" # --- Cohere --- # Requires: COHERE_API_KEY diff --git a/forge-go/control/handler.go b/forge-go/control/handler.go index 97317de..eca19fc 100644 --- a/forge-go/control/handler.go +++ b/forge-go/control/handler.go @@ -372,28 +372,33 @@ func (h *ControlQueueHandler) handleSpawn(ctx context.Context, req *protocol.Spa } if pSup, ok := sup.(*supervisor.ProcessSupervisor); ok { - if status, _ := pSup.Status(ctx, req.GuildID, req.AgentSpec.ID); status == "running" { - nodeID, _ := os.Hostname() - if nodeID == "" { - nodeID = "localhost" + nodeID, _ := os.Hostname() + if nodeID == "" { + nodeID = "localhost" + } + msg.NodeID = nodeID + + // The launch succeeded, so a PID exists. Prefer the live PID, but fall + // back to the PID captured at launch: a short-lived agent may already + // have exited (clearing the live PID and flipping state to restarting) + // by the time we read it, and we must not report success with PID 0. + for retries := 0; retries < 5; retries++ { + if pid, err := pSup.GetPID(ctx, req.GuildID, req.AgentSpec.ID); err == nil && pid > 0 { + msg.PID = pid + break } - msg.NodeID = nodeID - - for retries := 0; retries < 5; retries++ { - actualPid, err := pSup.GetPID(ctx, req.GuildID, req.AgentSpec.ID) - if err == nil && actualPid > 0 { - msg.PID = actualPid - break - } - time.Sleep(100 * time.Millisecond) + if pid, err := pSup.GetLaunchPID(ctx, req.GuildID, req.AgentSpec.ID); err == nil && pid > 0 { + msg.PID = pid + break } + time.Sleep(100 * time.Millisecond) + } - if msg.PID <= 0 { - if !shouldSuppressSpawnResponse(req) { - h.sendError(ctx, req.RequestID, "timed out waiting to retrieve valid PID for spawned agent") - } - return + if msg.PID <= 0 { + if !shouldSuppressSpawnResponse(req) { + h.sendError(ctx, req.RequestID, "timed out waiting to retrieve valid PID for spawned agent") } + return } } diff --git a/forge-go/go.mod b/forge-go/go.mod index 7a3918a..612c918 100644 --- a/forge-go/go.mod +++ b/forge-go/go.mod @@ -67,7 +67,6 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect @@ -79,12 +78,23 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect github.com/aws/smithy-go v1.25.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.1 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -92,13 +102,13 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect @@ -140,9 +150,12 @@ require ( github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/minio/highwayhash v1.0.4 // indirect @@ -152,6 +165,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.1.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/nkeys v0.4.15 // indirect @@ -171,6 +187,7 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -179,6 +196,7 @@ require ( github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/gopher-lua v1.1.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect @@ -191,7 +209,6 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/arch v0.27.0 // indirect golang.org/x/crypto v0.51.0 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/forge-go/go.sum b/forge-go/go.sum index 114d581..3f577ce 100644 --- a/forge-go/go.sum +++ b/forge-go/go.sum @@ -1,55 +1,35 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= -cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= -cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/logging v1.17.0 h1:rUFekZYwHiKElXCyz3zYBGz4BOeIqzgCKxVLdgrZ5mY= -cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/logging v1.17.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= cloud.google.com/go/longrunning v0.12.0 h1:wLv2hXvID9zHejLtcPo1B0JBjErnwZCYAPKSTa65xpY= -cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= -cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/longrunning v0.12.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4= -cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= -cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= -cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= cloud.google.com/go/trace v1.15.0 h1:kAYkTwKyYHkGtAGFuu6qaUFRBkOVr+d1Yo44yZtGtgg= +cloud.google.com/go/trace v1.15.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -62,94 +42,52 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= -github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= -github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/antithesishq/antithesis-sdk-go v0.7.0 h1:uWDG8BqLD1lI2ps38WDz2vXflrTX2+vLX0SvZtztJtE= github.com/antithesishq/antithesis-sdk-go v0.7.0/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= -github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= -github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk= -github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI= github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g= github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 h1:Zy6Tme1AA13kX8x3CnkHx5cqdGWGaj/anwOiWGnA0Xo= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12/go.mod h1:ql4uXYKoTM9WUAUSmthY4AtPVrlTBZOvnBJTiCUdPxI= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 h1:9XFUd2lkr7VrbE4Qtrhm7AtNhGgZeGFI5QLZtQIflj8= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18/go.mod h1:trImuKdWelQIJALvyGj6sKolJ1W8t628JOoTdDGVL9Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg= -github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -159,16 +97,10 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -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/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= -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 v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= -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/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprYoU= @@ -178,14 +110,30 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -201,16 +149,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -219,34 +163,25 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -260,7 +195,6 @@ 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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -270,8 +204,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -279,8 +211,6 @@ github.com/go-zeromq/goczmq/v4 v4.2.2 h1:HAJN+i+3NW55ijMJJhk7oWxHKXgAuSBkoFfvr8b github.com/go-zeromq/goczmq/v4 v4.2.2/go.mod h1:Sm/lxrfxP/Oxqs0tnHD6WAhwkWrx+S+1MRrKzcxoaYE= github.com/go-zeromq/zmq4 v0.17.0 h1:r12/XdqPeRbuaF4C3QZJeWCt7a5vpJbslDH1rTXF+Kc= github.com/go-zeromq/zmq4 v0.17.0/go.mod h1:EQxjJD92qKnrsVMzAnx62giD6uJIPi1dMGZ781iCDtY= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -307,7 +237,6 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/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-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= @@ -327,29 +256,20 @@ 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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= -github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -364,10 +284,11 @@ github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/memberlist v0.5.4 h1:40YY+3qq2tAUhZIMEK8kqusKZBBjdwJ3NUjvYkcxh74= github.com/hashicorp/memberlist v0.5.4/go.mod h1:OgN6xiIo6RlHUWk+ALjP9e32xWCoQrsOCmHrWCm2MWA= github.com/hashicorp/raft v1.7.3 h1:DxpEqZJysHN0wK+fviai5mFcSYsCkNpFUl1xpAW8Rbo= @@ -378,8 +299,6 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= @@ -398,8 +317,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -418,34 +335,27 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= -github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -465,30 +375,28 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= -github.com/nats-io/nats-server/v2 v2.12.6 h1:Egbx9Vl7Ch8wTtpXPGqbehkZ+IncKqShUxvrt1+Enc8= -github.com/nats-io/nats-server/v2 v2.12.6/go.mod h1:4HPlrvtmSO3yd7KcElDNMx9kv5EBJBnJJzQPptXlheo= github.com/nats-io/nats-server/v2 v2.14.0 h1:+8q0HrDFotwLLcGH/legOEOnowunhK+aZ4GYBIWpQlM= github.com/nats-io/nats-server/v2 v2.14.0/go.mod h1:ImVUUDvfClJbb6cuJQRc1VmgDCXKM5ds0OoiG9MVOKo= -github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= -github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= -github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -497,8 +405,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -510,8 +416,6 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -539,20 +443,18 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -562,13 +464,10 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUt github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= @@ -599,12 +498,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= @@ -612,84 +507,50 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= -go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -698,30 +559,18 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY= gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI= -golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= -golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -730,13 +579,9 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -762,56 +607,36 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/api v0.270.0 h1:4rJZbIuWSTohczG9mG2ukSDdt9qKx4sSSHIydTN26L4= -google.golang.org/api v0.270.0/go.mod h1:5+H3/8DlXpQWrSz4RjGGwz5HfJAQSEI8Bc6JqQNH77U= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E= google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8= google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA= google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -844,34 +669,28 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= -modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= diff --git a/forge-go/guild/builder.go b/forge-go/guild/builder.go index 5da822a..e9a86d3 100644 --- a/forge-go/guild/builder.go +++ b/forge-go/guild/builder.go @@ -381,6 +381,23 @@ func (b *GuildBuilder) mergeDependencyMap(configPath string) error { return nil } +// RenderConfiguration substitutes the spec's Configuration bag into its agents +// and routing steps via mustache, returning the rendered spec. It is the +// launch-time counterpart of Python's GuildBuilder._from_spec_dict rendering: +// unlike BuildSpec it applies no defaults, merges no dependencies, and runs no +// validation — it only resolves {{ }} placeholders. With an empty Configuration +// it is a no-op, so callers can invoke it unconditionally. +func RenderConfiguration(spec *protocol.GuildSpec) (*protocol.GuildSpec, error) { + b := GuildBuilderFromSpec(spec) + if b.err != nil { + return nil, b.err + } + if err := b.resolveTemplates(); err != nil { + return nil, err + } + return &b.spec, nil +} + func (b *GuildBuilder) resolveTemplates() error { if len(b.spec.Configuration) == 0 { return nil diff --git a/forge-go/registry/registry.go b/forge-go/registry/registry.go index a1eba3b..d24f127 100644 --- a/forge-go/registry/registry.go +++ b/forge-go/registry/registry.go @@ -163,6 +163,9 @@ func ResolveCommand(entry *AgentRegistryEntry) []string { switch entry.Runtime { case RuntimeUVX: cmd = append(cmd, ResolveUVXCommand()) + if py := UVPython(); py != "" { + cmd = append(cmd, "--python", py) + } forgePkg := os.Getenv("FORGE_PYTHON_PKG") if forgePkg == "" { forgePkg = "rusticai-forge" @@ -198,8 +201,22 @@ func ResolveCommand(entry *AgentRegistryEntry) []string { } default: - cmd = append(cmd, ResolveUVXCommand(), "python", "-m", "rustic_ai.forge.agent_runner") + cmd = append(cmd, ResolveUVXCommand()) + if py := UVPython(); py != "" { + cmd = append(cmd, "--python", py) + } + cmd = append(cmd, "python", "-m", "rustic_ai.forge.agent_runner") } return cmd } + +// UVPython returns the interpreter request used to pin uv/uvx when spawning +// Python agents (passed as `uvx --python …`). The value is a uv Python +// request: a bare version like "3.13", a constraint like ">=3.13,<3.14", or an +// absolute interpreter path. It is sourced from FORGE_UV_PYTHON, which the +// server/client/CLI set from their --uv-python flags. Empty means "let uv pick" +// (uv's default discovery), preserving prior behaviour. +func UVPython() string { + return strings.TrimSpace(os.Getenv("FORGE_UV_PYTHON")) +} diff --git a/forge-go/supervisor/managed.go b/forge-go/supervisor/managed.go index 1fe3cfe..393db8e 100644 --- a/forge-go/supervisor/managed.go +++ b/forge-go/supervisor/managed.go @@ -23,6 +23,7 @@ type ManagedAgent struct { ID string State AgentState PID int + LaunchPID int RestartCount int LastError error @@ -59,6 +60,13 @@ func (m *ManagedAgent) SetPID(pid int) { m.mu.Lock() defer m.mu.Unlock() m.PID = pid + if pid > 0 { + // LaunchPID records the PID of the most recent launch and, unlike PID, + // is not cleared when the process exits. It lets a spawn response report + // the PID it launched even for a short-lived agent that has already + // exited by the time the response is built. + m.LaunchPID = pid + } m.StartedAt = time.Now() } @@ -74,6 +82,14 @@ func (m *ManagedAgent) GetPID() int { return m.PID } +// GetLaunchPID returns the PID captured at the most recent launch. It remains +// valid after the process exits (until the next launch overwrites it). +func (m *ManagedAgent) GetLaunchPID() int { + m.mu.RLock() + defer m.mu.RUnlock() + return m.LaunchPID +} + func (m *ManagedAgent) RequestStop() { m.mu.Lock() defer m.mu.Unlock() diff --git a/forge-go/supervisor/managed_test.go b/forge-go/supervisor/managed_test.go new file mode 100644 index 0000000..f7e4576 --- /dev/null +++ b/forge-go/supervisor/managed_test.go @@ -0,0 +1,34 @@ +package supervisor + +import "testing" + +// TestManagedAgent_LaunchPIDSurvivesExit pins the invariant the spawn-response +// path relies on: ClearPID (called when a process exits) zeroes the live PID, +// but the launch PID remains readable so a short-lived agent's spawn response +// can still report the PID it launched instead of 0. +func TestManagedAgent_LaunchPIDSurvivesExit(t *testing.T) { + m := NewManagedAgent("guild-1", "agent-1") + + m.SetPID(4242) + if got := m.GetPID(); got != 4242 { + t.Fatalf("GetPID after launch = %d, want 4242", got) + } + if got := m.GetLaunchPID(); got != 4242 { + t.Fatalf("GetLaunchPID after launch = %d, want 4242", got) + } + + // Process exits: the monitor clears the live PID. + m.ClearPID() + if got := m.GetPID(); got != 0 { + t.Errorf("GetPID after exit = %d, want 0", got) + } + if got := m.GetLaunchPID(); got != 4242 { + t.Errorf("GetLaunchPID after exit = %d, want 4242 (must survive exit)", got) + } + + // A restart assigns a new PID; the launch PID tracks the latest launch. + m.SetPID(5353) + if got := m.GetLaunchPID(); got != 5353 { + t.Errorf("GetLaunchPID after restart = %d, want 5353", got) + } +} diff --git a/forge-go/supervisor/process.go b/forge-go/supervisor/process.go index 8c6946d..533e609 100644 --- a/forge-go/supervisor/process.go +++ b/forge-go/supervisor/process.go @@ -540,6 +540,22 @@ func (p *ProcessSupervisor) GetPID(ctx context.Context, guildID, agentID string) return agent.GetPID(), nil } +// GetLaunchPID returns the PID captured at the agent's most recent launch, which +// stays valid even after a short-lived process exits (unlike GetPID, which is +// cleared on exit). +func (p *ProcessSupervisor) GetLaunchPID(ctx context.Context, guildID, agentID string) (int, error) { + key := scopedAgentKey(guildID, agentID) + p.mu.RLock() + agent, exists := p.agents[key] + p.mu.RUnlock() + + if !exists { + return 0, fmt.Errorf("agent %s not managed in guild %s", agentID, normalizeGuildID(guildID)) + } + + return agent.GetLaunchPID(), nil +} + func (p *ProcessSupervisor) StopAll(ctx context.Context) error { p.mu.RLock() agents := make([]*ManagedAgent, 0, len(p.agents)) diff --git a/guilds/echo_app.json b/guilds/echo_app.json new file mode 100644 index 0000000..5ea9967 --- /dev/null +++ b/guilds/echo_app.json @@ -0,0 +1,72 @@ +{ + "name": "Simple Echo", + "description": "Simple Echo is a lightweight app that mirrors your input. Whatever you say, it says back—perfect for testing, demos, or lightweight message flows.", + "version": "v1", + "icon": null, + "intro_msg": "Hi there! I’ll echo back anything you send me. Give it a try!", + "exposure": "public", + "author_id": "dummyuserid", + "organization_id": null, + "category_id": null, + "tags": [], + "commands": [], + "starter_prompts": [], + "spec": { + "name": "Simple Echo", + "description": "Simple Echo is a lightweight app that mirrors your input. Whatever you say, it says back—perfect for testing, demos, or lightweight message flows.", + "properties": {}, + "agents": [ + { + "name": "Echo Agent", + "description": "An agent that echoes every message it receives—word for word.", + "class_name": "rustic_ai.core.agents.testutils.echo_agent.EchoAgent", + "additional_topics": ["echo_topic"], + "properties": {}, + "listen_to_default_topic": false, + "act_only_when_tagged": false + } + ], + "routes": { + "steps": [ + { + "agent": null, + "agent_type": "rustic_ai.core.agents.utils.user_proxy_agent.UserProxyAgent", + "method_name": "unwrap_and_forward_message", + "origin_filter": null, + "message_format": null, + "destination": { + "topics": "echo_topic", + "recipient_list": [], + "priority": null + }, + "mark_forwarded": false, + "route_times": 1, + "transformer": null, + "agent_state_update": null, + "guild_state_update": null + }, + { + "agent": { + "id": null, + "name": "Echo Agent" + }, + "agent_type": null, + "method_name": null, + "origin_filter": null, + "message_format": null, + "destination": { + "topics": "user_message_broadcast", + "recipient_list": [], + "priority": null + }, + "mark_forwarded": false, + "route_times": 1, + "transformer": null, + "agent_state_update": null, + "guild_state_update": null, + "process_status": "completed" + } + ] + } + } +} diff --git a/guilds/react_guild.json b/guilds/react_guild.json new file mode 100644 index 0000000..f6b8403 --- /dev/null +++ b/guilds/react_guild.json @@ -0,0 +1,202 @@ +{ + "name": "Data Analyst Assistant", + "description": "An intelligent data analysis assistant powered by the ReAct pattern with Codex-style dataset enrichment. Upload CSV files and ask questions about your data - the assistant will load, query, and analyze your datasets automatically.", + "version": "v1", + "icon": null, + "intro_msg": "Welcome to the Data Analyst Assistant! I can help you analyze CSV data files. Just tell me which file to load and what you'd like to know about the data. I can calculate statistics, run SQL queries, describe datasets, and more. When you load a dataset, I'll automatically analyze its structure to provide better insights.", + "exposure": "public", + "author_id": "dummyuserid", + "organization_id": "acmeorganizationid", + "category_id": null, + "tags": [ + "Data Analysis", + "CSV", + "SQL", + "Pandas", + "ReAct", + "Codex Enrichment" + ], + "commands": [], + "starter_prompts": [ + "Load sales_data.csv and show me the first 5 rows", + "What is the average price of products in the dataset?", + "Load the employees file and tell me the total salary by department", + "Show me the schema of the loaded dataset", + "Find all products with quantity greater than 30" + ], + "spec": { + "name": "Data Analyst Guild", + "description": "Guild for data analysis using ReActAgent with DataAnalystReActToolset and Codex enrichment", + "properties": {}, + "configuration_schema": { + "type": "object", + "properties": { + "default_model": { + "type": "string", + "default": "gpt-4o-mini", + "description": "The LLM model to use for analysis" + } + } + }, + "configuration": {}, + "agents": [ + { + "id": "react_data_analyst", + "name": "Data Analyst Agent", + "description": "A ReAct-based agent that can load, query, and analyze datasets using pandas and SQL", + "class_name": "rustic_ai.llm_agent.react.react_agent.ReActAgent", + "additional_topics": [], + "properties": { + "max_iterations": 10, + "temperature": 0.7, + "system_prompt": "You are a Data Analyst Agent. You help users analyze data using pandas and SQL.\n\nYou have access to tools for loading, querying, and analyzing datasets:\n- load_file: Load a CSV, JSON, or Parquet file as a dataset\n- list_datasets: List all currently loaded datasets\n- get_schema: Get the schema/columns of a dataset\n- preview_dataset: Preview the first few rows of a dataset\n- describe_dataset: Get descriptive statistics for a dataset\n- query_dataset: Run SQL queries on datasets\n- summarize_dataset: Get a summary of a dataset\n- value_counts: Get value counts for a column\n- correlation_matrix: Get correlation matrix for numeric columns\n- transform_dataset: Apply transformations to a dataset\n- join_datasets: Join two datasets together\n- clean_dataset: Clean missing/invalid data\n\nUse the ReAct pattern to answer user questions about data:\n1. Think about what information you need\n2. Use tools to load and explore the data\n3. Analyze the results and provide insights\n\nIMPORTANT: When the user uploads a file, the file path will be provided in a system message. Use that exact path with the load_file tool to load the data.\n\nNOTE: If enrichment context is provided for a dataset, use that information to better understand the data structure, semantics, and recommended usage patterns.", + "request_preprocessors": [ + { + "kind": "rustic_ai.pandas_analyst.file_url_preprocessor.FileUrlExtractorPreprocessor" + }, + { + "kind": "rustic_ai.pandas_analyst.enrichment.preprocessor.EnrichmentContextPreprocessor" + } + ], + "tool_wrappers": [ + { + "kind": "rustic_ai.pandas_analyst.enrichment.emitter.DatasetLoadedEmitter", + "sample_rows": 5 + } + ], + "toolset": { + "kind": "rustic_ai.pandas_analyst.react_toolset.DataAnalystReActToolset", + "filesystem_base_path": "/tmp", + "filesystem_protocol": "file", + "filesystem_options": { + "auto_mkdir": true + } + } + }, + "listen_to_default_topic": true, + "act_only_when_tagged": false, + "predicates": {}, + "dependency_map": {}, + "additional_dependencies": [], + "resources": { + "num_cpus": null, + "num_gpus": null, + "custom_resources": {} + }, + "qos": { + "timeout": null, + "retry_count": null, + "latency": null + } + }, + { + "id": "codex_enrichment_llm", + "name": "Codex Enrichment LLM", + "description": "LLM agent that generates rich dataset metadata for Codex-style enrichment", + "class_name": "rustic_ai.llm_agent.llm_agent.LLMAgent", + "additional_topics": [ + "enrichment_requests" + ], + "properties": { + "default_system_prompt": "You are a data catalog expert. Analyze the provided dataset information and generate rich metadata as JSON.\n\nReturn a JSON object with these fields:\n- table_purpose: Brief description of what this table is for (1 sentence)\n- table_description: Detailed description of the table's contents and structure\n- grain_description: What each row in the table represents (e.g., \"One row per customer order\")\n- primary_key_candidates: Array of column names that could serve as primary keys\n- columns: Array of column objects, each with:\n - name: Column name\n - description: What this column represents\n - semantic_type: One of (identifier, dimension, measure, timestamp, category, text)\n - uniqueness: One of (unique, mostly_unique, low_cardinality, unknown)\n - is_primary_key_candidate: boolean\n- freshness_indicators: Object with date range info if applicable\n- recommended_usage: Array of suggested analyses or queries\n- when_to_use: Guidance on when to use this dataset\n- limitations: Array of known data limitations or caveats\n\nBe concise but informative. Focus on practical insights that would help a data analyst work with this data effectively.\n\nReturn ONLY the JSON object, no additional text.", + "temperature": 0.3 + }, + "listen_to_default_topic": false, + "act_only_when_tagged": false, + "predicates": {}, + "dependency_map": {}, + "additional_dependencies": [], + "resources": { + "num_cpus": null, + "num_gpus": null, + "custom_resources": {} + }, + "qos": { + "timeout": null, + "retry_count": null, + "latency": null + } + } + ], + "dependency_map": {}, + "routes": { + "steps": [ + { + "agent": { + "id": null, + "name": "Data Analyst Agent" + }, + "agent_type": null, + "method_name": null, + "origin_filter": null, + "message_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse", + "destination": { + "topics": "user_message_broadcast", + "recipient_list": [], + "priority": null + }, + "mark_forwarded": true, + "route_times": -1, + "transformer": null, + "agent_state_update": null, + "guild_state_update": null, + "process_status": "completed", + "reason": "Send analysis results back to user" + }, + { + "agent": { + "id": null, + "name": "Data Analyst Agent" + }, + "agent_type": null, + "method_name": null, + "origin_filter": null, + "message_format": "rustic_ai.pandas_analyst.enrichment.models.DatasetLoadedEvent", + "destination": { + "topics": "enrichment_requests", + "recipient_list": [], + "priority": null + }, + "mark_forwarded": true, + "route_times": -1, + "transformer": { + "style": "simple", + "output_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionRequest", + "expression": "{'messages': [{'role': 'user', 'content': 'Analyze this dataset and generate enrichment metadata:\\n\\nDataset: ' & dataset_name & '\\nRows: ' & $string(row_count) & '\\nColumns: ' & $string(column_count) & '\\n\\nColumn Names: ' & $join(columns, ', ') & '\\n\\nSchema (column -> type):\\n' & $string(column_schema) & '\\n\\nSample Data (first ' & $string($count(sample_data)) & ' rows):\\n' & $string(sample_data)}]}" + }, + "agent_state_update": null, + "guild_state_update": null, + "process_status": null, + "reason": "Transform DatasetLoadedEvent to ChatCompletionRequest for enrichment LLM" + }, + { + "agent": { + "id": null, + "name": "Codex Enrichment LLM" + }, + "agent_type": null, + "method_name": null, + "origin_filter": null, + "message_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse", + "destination": { + "topics": "enrichment_results", + "recipient_list": [], + "priority": null + }, + "mark_forwarded": true, + "route_times": -1, + "transformer": null, + "agent_state_update": null, + "guild_state_update": { + "expression_type": "jsonata", + "update_format": "json-merge-patch", + "state_update": "($content := origin.payload.messages[0].content; $dataset_name := $match($content, /Dataset: (\\S+)/).groups[0]; $row_count := $number($match($content, /Rows: (\\d+)/).groups[0]); $column_count := $number($match($content, /Columns: (\\d+)/).groups[0]); $llm_response := payload.choices[0].message.content; $clean_json := $trim($replace($replace($llm_response, /^```json\\s*/, ''), /\\s*```$/, '')); $enrichment := $eval($clean_json); {'codex_enrichment': {'datasets': {$dataset_name: $merge([$enrichment, {'dataset_name': $dataset_name, 'row_count': $row_count, 'column_count': $column_count, 'enrichment_timestamp': $now()}])}}})" + }, + "process_status": "completed", + "reason": "Store enrichment response in guild state" + } + ] + }, + "gateway": null + } +} diff --git a/guilds/uniko_research_guild.json b/guilds/uniko_research_guild.json new file mode 100644 index 0000000..7cbfa0a --- /dev/null +++ b/guilds/uniko_research_guild.json @@ -0,0 +1,479 @@ +{ + "name": "Uniko Research Guild", + "description": "Advanced research guild with persistent memory using Uniko cognitive memory system, combining KB search, web scraping, and intelligent memory management.", + "version": "v2", + "icon": null, + "intro_msg": "Welcome to the Uniko Research Guild! I can help you research topics with persistent memory. What would you like to explore?", + "exposure": "public", + "author_id": "dummyuserid", + "organization_id": "acmeorganizationid", + "category_id": null, + "tags": [ + "research", + "memory", + "uniko", + "knowledge-base" + ], + "commands": [], + "starter_prompts": [ + "Research recent developments in quantum computing", + "Find information about sustainable energy solutions", + "What have I previously researched about AI?" + ], + "spec": { + "name": "Uniko Research Guild", + "description": "Advanced research guild with persistent memory using Uniko cognitive memory system, combining KB search, web scraping, and intelligent memory management.", + "agents": [ + { + "id": "memory_agent", + "name": "Memory Agent", + "description": "Comprehensive memory agent using Uniko's 5-tier cognitive memory system for persistent knowledge storage and recall", + "class_name": "rustic_ai.uniko_agent.agent.MemoryAgent", + "additional_topics": [ + "memory_observe", + "memory_recall", + "memory_answer", + "memory_ingest" + ], + "listen_to_default_topic": true, + "properties": { + "default_session_id": null, + "recall_max_tokens": 3000, + "recall_phase1_only": false, + "answer_max_tokens": 800, + "auto_flush": true + } + }, + { + "id": "google_agent", + "name": "Google Research Agent", + "description": "Performs intelligent Google Search with grounding", + "class_name": "rustic_ai.vertexai.agents.google_research_agent.GoogleResearchAgent", + "additional_topics": [], + "listen_to_default_topic": true, + "properties": { + "project_id": null, + "location": null, + "model_id": "gemini-2.5-pro" + } + }, + { + "id": "serp_agent", + "name": "Search Agent", + "description": "Handles search engine results page queries via SerpAPI", + "class_name": "rustic_ai.serpapi.agent.SERPAgent", + "additional_topics": [], + "properties": {}, + "listen_to_default_topic": true + }, + { + "id": "playwright_agent", + "name": "Web Scraper Agent", + "description": "Web scraping agent using Playwright for content extraction", + "class_name": "rustic_ai.playwright.agent.PlaywrightScraperAgent", + "additional_topics": [], + "properties": { + "headless": true, + "parallel_pages": 5 + }, + "listen_to_default_topic": true + }, + { + "id": "markdown_agent", + "name": "Markdown Agent", + "description": "Generates and manages Markdown files from research results", + "class_name": "rustic_ai.demos.agents.preprocessing.file_reader_writer_agent.FileReaderWriterAgent", + "additional_topics": [], + "listen_to_default_topic": true, + "properties": {} + }, + { + "id": "synthesis_agent", + "name": "Synthesis Agent", + "description": "Synthesizes research findings and generates comprehensive answers", + "class_name": "rustic_ai.llm_agent.llm_agent.LLMAgent", + "additional_topics": [ + "synthesis_agent" + ], + "listen_to_default_topic": false, + "properties": { + "model": "vertex_ai/gemini-3-pro-preview", + "default_system_prompt": "You are a research synthesis expert. Analyze the provided context from multiple sources and generate a comprehensive, well-structured answer to the user's question. Include key insights, relevant facts, and cite sources when applicable. Be thorough but concise." + } + }, + { + "id": "query_agent", + "name": "Query Agent", + "description": "Generates optimized search queries using LLM for comprehensive research coverage", + "class_name": "rustic_ai.llm_agent.llm_agent.LLMAgent", + "additional_topics": [ + "query_agent" + ], + "listen_to_default_topic": false, + "properties": { + "model": "vertex_ai/gemini-3-pro-preview", + "default_system_prompt": "Generate 5-7 comprehensive google-optimized subqueries for deep research on the topic.\n\nFor thorough coverage, create subqueries across multiple dimensions:\n- Core concepts and definitions\n- Latest developments and breakthroughs (recent news, 2025-2026)\n- Historical context and evolution\n- Technical implementations and real-world applications\n- Expert opinions, academic research, and papers\n- Industry trends, market analysis, and adoption\n- Future implications, predictions, and emerging patterns\n- Challenges, limitations, and critiques\n- Related technologies, comparisons, and alternatives\n- Case studies and practical examples\n\nConsider when creating subqueries:\n- Neural search formulation: Use natural language questions and descriptive phrases\n- Domain filters: Suggest specific domains when relevant (arxiv.org for papers, techcrunch.com for tech news)\n- Time periods: Specify recent, past_week, past_month, past_year, or any\n- Content types: Specify type when relevant (news, research paper, pdf, blog, tutorial)\n\nEach subquery should focus on a different aspect to ensure comprehensive, multi-dimensional coverage.\n\nOutput format:\n- Separate queries using '####' as delimiter\n- Example: Query 1####Query 2####Query 3\n- Return ONLY the queries, nothing else" + } + }, + { + "id": "splitter_agent", + "name": "Splitter Agent", + "description": "Splits generated queries into individual search requests", + "class_name": "rustic_ai.core.agents.eip.splitter_agent.SplitterAgent", + "additional_topics": [ + "splitter_agent" + ], + "properties": { + "splitter": { + "split_type": "jsonata", + "expression": "($map($split($.choices[0].message.content, '####'), function($v){ ({\"query\": $v, \"engine\": \"google\", \"num\": 8}) }))" + }, + "format_selector": { + "strategy": "fixed", + "fixed_format": "rustic_ai.serpapi.agent.SERPQuery" + } + }, + "listen_to_default_topic": false + }, + { + "id": "basic_wiring_agent", + "name": "Basic Wiring Agent", + "description": "Routes and transforms messages between agents", + "class_name": "rustic_ai.core.agents.eip.basic_wiring_agent.BasicWiringAgent", + "additional_topics": [ + "basic_wiring" + ], + "listen_to_default_topic": false, + "properties": {} + }, + { + "id": "gateway_agent", + "name": "Research Gateway", + "description": "Gateway agent for receiving research requests from other guilds", + "class_name": "rustic_ai.core.guild.g2g.gateway_agent.GatewayAgent", + "additional_topics": [ + "outbound" + ], + "listen_to_default_topic": false, + "properties": { + "input_formats": [ + "rustic_ai.demos.research_guild.types.ResearchRequest" + ], + "output_formats": [ + "rustic_ai.demos.research_guild.types.ResearchResults" + ], + "returned_formats": [] + } + } + ], + "dependency_map": { + "filesystem": { + "class_name": "rustic_ai.core.guild.agent_ext.depends.filesystem.filesystem.FileSystemResolver", + "properties": { + "path_base": "/tmp/research_guild", + "protocol": "file", + "storage_options": { + "auto_mkdir": true + }, + "asynchronous": true + } + }, + "llm": { + "class_name": "rustic_ai.litellm.agent_ext.llm.LiteLLMResolver", + "properties": { + "model": "vertex_ai/gemini-3-pro-preview", + "conf": { + "vertex_location": "global" + } + } + }, + "uniko": { + "class_name": "rustic_ai.uniko_agent.resolver.UnikoResolver", + "properties": { + "storage_path": "./data/memory/{org_id}/{guild_id}", + "llm_spec": { + "alias": "openai", + "model_id": "gpt-4o-mini", + "key_env": "OPENAI_API_KEY" + }, + "streaming": false + } + } + }, + "routes": { + "steps": [ + { + "agent_type": "rustic_ai.core.agents.utils.user_proxy_agent.UserProxyAgent", + "method_name": "unwrap_and_forward_message", + "message_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionRequest", + "destination": null, + "route_times": -1, + "transformer": { + "style": "content_based_router", + "handler": "($content := $type($.payload.messages[0].content) = \"string\" ? [{\"type\": \"text\", \"text\": $.payload.messages[0].content}] : $.payload.messages[0].content; $hasFiles := $count($content[type in [\"image_url\", \"file_url\"]]) > 0; $textContent := $content[type = \"text\"][0].text; $urls := $match($textContent, /(https?:\\/\\/[^\\s]+|www\\.[^\\s]+)/).match; $hasUrls := $count($urls) > 0; $hasFiles ? {\"format\": \"rustic_ai.uniko_agent.models.IngestDocumentRequest\", \"topics\": \"memory_ingest\", \"payload\": {\"source_spec\": $content[type in [\"image_url\", \"file_url\"]][0].type = \"image_url\" ? {\"url\": $content[type in [\"image_url\", \"file_url\"]][0].image_url.url} : {\"url\": $content[type in [\"image_url\", \"file_url\"]][0].file_url.url}}, \"context\": {\"original_query\": $textContent, \"current_id\": 'current_id' in $keys($.guild_state) ? $.guild_state.current_id + 1 : 0}} : $hasUrls ? {\"format\": \"rustic_ai.playwright.agent.WebScrapingRequest\", \"topics\": \"default_topic\", \"payload\": {\"links\": [$map($urls, function($url){ {\"url\": $url} })], \"depth\": \"0\", \"output_format\": \"text/markdown\"}, \"context\": {\"original_query\": $textContent, \"current_id\": 'current_id' in $keys($.guild_state) ? $.guild_state.current_id + 1 : 0}} : {\"payload\": {\"query\": $textContent}, \"format\": \"rustic_ai.uniko_agent.models.RecallRequest\", \"topics\": \"memory_recall\", \"context\": {\"original_query\": $textContent, \"current_id\": 'current_id' in $keys($.guild_state) ? $.guild_state.current_id + 1 : 0}})" + }, + "guild_state_update": { + "update_format": "json-merge-patch", + "state_update": "({\"user_queries\": $append($.guild_state.user_queries, $.payload.text), \"current_id\": 'current_id' in $keys($.guild_state) ? $.guild_state.current_id + 1 : 0})" + } + }, + { + "agent": { + "id": "memory_agent", + "name": "Memory Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.RecallResponse", + "destination": null, + "route_times": -1, + "transformer": { + "style": "content_based_router", + "handler": "($is_answered := $.context.current_id in $.guild_state.answered; $has_good_recall := $count($.payload.items[score > 0.3]) > 0; $ToAnswer := function($items){ {\"topics\": \"memory_answer\", \"format\": \"rustic_ai.uniko_agent.models.AnswerRequest\", \"payload\": {\"question\": $.context.original_query, \"max_tokens\": 800}} }; $ToQueryAgent := function(){ {\"topics\": \"query_agent\", \"format\": \"rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionRequest\", \"payload\": {\"messages\": [{\"role\": \"user\", \"content\": \"User Question: \" & $.context.original_query}]}} }; $is_answered ? null : ($has_good_recall ? $ToAnswer($.payload.items) : $ToQueryAgent()))" + }, + "guild_state_update": { + "update_format": "json-merge-patch", + "state_update": "($is_answered := $.context.current_id in $.guild_state.answered; $has_good_recall := $count($.untransformed.payload.items[score > 0.3]) > 0; $UpdateGuildState := {\"answered\": $append(guild_state.answered, $.context.current_id)}; $is_answered ? null : ($has_good_recall ? $UpdateGuildState : null))" + } + }, + { + "agent": { + "name": "Memory Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.AnswerResponse", + "destination": { + "topics": "user_message_broadcast", + "recipient_list": [], + "priority": null + }, + "route_times": -1, + "transformer": { + "style": "simple", + "output_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse", + "expression": "({\"choices\": [{\"message\": {\"content\": $.text}, \"finish_reason\": \"stop\"}], \"usage\": {\"prompt_tokens\": $.input_tokens, \"completion_tokens\": $.output_tokens, \"total_tokens\": $.input_tokens + $.output_tokens}})" + }, + "process_status": "completed" + }, + { + "agent": { + "name": "Research Gateway" + }, + "message_format": "rustic_ai.demos.research_guild.types.ResearchRequest", + "destination": null, + "route_times": -1, + "transformer": { + "style": "content_based_router", + "handler": "({\"payload\":{\"query\": $.payload.topic}, \"format\": \"rustic_ai.uniko_agent.models.RecallRequest\", \"topics\":\"memory_recall\", \"context\": {\"original_query\": $.payload.topic, \"current_id\": 'current_id' in $keys($.guild_state) ? $.guild_state.current_id + 1 : 0, \"correlation_id\": $.payload.correlation_id}})" + }, + "guild_state_update": { + "update_format": "json-merge-patch", + "state_update": "({\"user_queries\": $append($.guild_state.user_queries, $.payload.topic), \"current_id\": 'current_id' in $keys($.guild_state) ? $.guild_state.current_id + 1 : 0})" + } + }, + { + "agent": { + "name": "Research Gateway" + }, + "message_format": "rustic_ai.demos.research_guild.types.ResearchRequest", + "destination": null, + "transformer": { + "style": "content_based_router", + "handler": "({\"format\": \"rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse\", \"topics\": \"user_message_broadcast\", \"payload\":{\"choices\": [{\"message\": {\"content\": 'Research initiated for: ' & $.payload.topic & '. Checking memory and initiating comprehensive web research...'}}]}})" + }, + "route_times": -1, + "process_status": "completed" + }, + { + "agent": { + "name": "Query Agent" + }, + "message_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse", + "destination": { + "topics": "splitter_agent", + "recipient_list": [], + "priority": null + }, + "route_times": -1 + }, + { + "agent": { + "name": "Splitter Agent" + }, + "message_format": "rustic_ai.serpapi.agent.SERPQuery", + "destination": { + "topics": "default_topic", + "recipient_list": [], + "priority": null + }, + "route_times": -1 + }, + { + "agent": { + "name": "Google Research Agent" + }, + "message_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse", + "destination": { + "topics": "default_topic", + "recipient_list": [], + "priority": null + }, + "route_times": -1 + }, + { + "agent": { + "name": "Markdown Agent" + }, + "message_format": "rustic_ai.core.agents.commons.media.MediaLink", + "destination": { + "topics": "memory_ingest", + "recipient_list": [], + "priority": null + }, + "route_times": -1, + "transformer": { + "style": "simple", + "output_format": "rustic_ai.uniko_agent.models.IngestDocumentRequest", + "expression": "{\"source_spec\": {\"url\": $.url}}" + } + }, + { + "agent": { + "name": "Search Agent" + }, + "message_format": "rustic_ai.serpapi.agent.SERPResults", + "destination": { + "topics": "default_topic", + "recipient_list": [], + "priority": null + }, + "route_times": -1, + "transformer": { + "style": "simple", + "output_format": "rustic_ai.playwright.agent.WebScrapingRequest", + "expression": "({\"id\": $.id, \"links\": $.results[url ~> /linkedin|facebook|instagram|twitter/ ? false : true], \"depth\": \"0\", \"output_format\": \"text/markdown\"})" + } + }, + { + "agent": { + "name": "Web Scraper Agent" + }, + "message_format": "rustic_ai.core.agents.commons.media.MediaLink", + "destination": { + "topics": "memory_ingest", + "recipient_list": [], + "priority": null + }, + "route_times": -1, + "transformer": { + "style": "simple", + "output_format": "rustic_ai.uniko_agent.models.IngestDocumentRequest", + "expression": "{\"source_spec\": {\"url\": $.url}}" + } + }, + { + "agent": { + "name": "Memory Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.IngestOutcome", + "destination": { + "topics": "basic_wiring", + "recipient_list": [], + "priority": null + }, + "route_times": -1 + }, + { + "agent": { + "name": "Basic Wiring Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.IngestOutcome", + "destination": { + "topics": "memory_observe", + "recipient_list": [], + "priority": null + }, + "route_times": -1, + "transformer": { + "style": "content_based_router", + "handler": "({\"format\": \"rustic_ai.uniko_agent.models.ObserveTurnRequest\", \"payload\": {\"sender_id\": \"memory_agent\", \"content\": \"Ingested document: \" & $string($.payload.chunk_count) & \" chunks, \" & $string($count($.payload.extracted_entities)) & \" entities extracted\", \"metadata\": {\"chunk_count\": $.payload.chunk_count, \"page_count\": $.payload.page_count, \"artifact_node_id\": $.payload.artifact_node_id}}})" + } + }, + { + "agent": { + "name": "Memory Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.ObserveResult", + "destination": { + "topics": "basic_wiring", + "recipient_list": [], + "priority": null + }, + "route_times": -1 + }, + { + "agent": { + "name": "Basic Wiring Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.ObserveResult", + "destination": { + "topics": "memory_recall", + "recipient_list": [], + "priority": null + }, + "route_times": -1, + "transformer": { + "style": "content_based_router", + "handler": "{\"payload\": {\"query\": $.context.original_query}, \"format\": \"rustic_ai.uniko_agent.models.RecallRequest\"}" + } + }, + { + "agent": { + "name": "Memory Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.RecallResponse", + "destination": null, + "route_times": -1, + "transformer": { + "style": "content_based_router", + "handler": "($is_answered := $.context.current_id in $.guild_state.answered; $recalled_content := $join($.payload.items.content, '\\n\\n'); $ToSynthesis := function(){ {\"topics\": \"synthesis_agent\", \"format\": \"rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionRequest\", \"payload\": {\"messages\": [{\"role\": \"user\", \"content\": \"Context from research:\\n\" & $recalled_content & \"\\n\\nOriginal Question: \" & $.context.original_query & \"\\n\\nProvide a comprehensive answer based on the context above.\"}]}} }; $is_answered ? null : ($count($.payload.items) > 0 ? $ToSynthesis() : null))" + }, + "guild_state_update": { + "update_format": "json-merge-patch", + "state_update": "($is_answered := $.context.current_id in $.guild_state.answered; $has_recall := $count($.untransformed.payload.items) > 0; $UpdateGuildState := {\"answered\": $append(guild_state.answered, $.context.current_id)}; $is_answered ? null : ($has_recall ? $UpdateGuildState : null))" + } + }, + { + "agent": { + "name": "Synthesis Agent" + }, + "route_times": -1, + "message_format": "rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse", + "destination": { + "topics": "memory_observe", + "recipient_list": [], + "priority": null + }, + "transformer": { + "style": "content_based_router", + "handler": "({\"format\": \"rustic_ai.uniko_agent.models.ObserveTurnRequest\", \"payload\": {\"sender_id\": \"synthesis_agent\", \"content\": $.choices[0].message.content, \"metadata\": {\"type\": \"synthesis_result\", \"original_query\": $.context.original_query}}})" + } + }, + { + "agent": { + "name": "Memory Agent" + }, + "message_format": "rustic_ai.uniko_agent.models.ObserveResult", + "destination": { + "topics": "user_message_broadcast", + "recipient_list": [], + "priority": null + }, + "route_times": -1, + "transformer": { + "style": "content_based_router", + "handler": "({\"format\": \"rustic_ai.core.guild.agent_ext.depends.llm.models.ChatCompletionResponse\", \"payload\": {\"choices\": [{\"message\": {\"content\": $.context.synthesized_response ? $.context.synthesized_response : \"Research completed and stored in memory. \" & $string($.payload.extracted_entities) & \" entities and \" & $string($.payload.extracted_observations) & \" observations extracted.\"}}]}})" + }, + "process_status": "completed" + } + ] + } + } +}