From 2e5287f7edec1556e346357e4cd7675225e723ab Mon Sep 17 00:00:00 2001 From: Nihal-Srivastava05 Date: Thu, 9 Jul 2026 19:28:45 +0530 Subject: [PATCH 01/16] feat: basic cli --- .python-version | 1 + GUILD_CLI_README.md | 261 +++++++++++ forge-go/cli/guild_runtime.go | 590 ++++++++++++++++++++++++ forge-go/cli/message_builder.go | 133 ++++++ forge-go/cli/subscription.go | 98 ++++ forge-go/command/guild.go | 86 ++++ forge-go/command/guild_inspect.go | 138 ++++++ forge-go/command/guild_run.go | 552 ++++++++++++++++++++++ forge-go/command/guild_validate.go | 113 +++++ forge-go/command/root.go | 3 + forge-go/conf/agent-dependencies.yaml | 56 +-- forge-go/conf/forge-agent-registry.yaml | 67 +-- forge-go/go.mod | 23 +- forge-go/go.sum | 295 +++--------- guilds/echo_app.json | 72 +++ guilds/react_guild.json | 202 ++++++++ guilds/uniko_research_guild.json | 479 +++++++++++++++++++ 17 files changed, 2870 insertions(+), 299 deletions(-) create mode 100644 .python-version create mode 100644 GUILD_CLI_README.md create mode 100644 forge-go/cli/guild_runtime.go create mode 100644 forge-go/cli/message_builder.go create mode 100644 forge-go/cli/subscription.go create mode 100644 forge-go/command/guild.go create mode 100644 forge-go/command/guild_inspect.go create mode 100644 forge-go/command/guild_run.go create mode 100644 forge-go/command/guild_validate.go create mode 100644 guilds/echo_app.json create mode 100644 guilds/react_guild.json create mode 100644 guilds/uniko_research_guild.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/GUILD_CLI_README.md b/GUILD_CLI_README.md new file mode 100644 index 0000000..377cc13 --- /dev/null +++ b/GUILD_CLI_README.md @@ -0,0 +1,261 @@ +# 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.21+ (for building) +- Redis or NATS backend + +## Installation + +```bash +cd /home/nihal/Projects/forge/forge-go +go build -o 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 custom Python +./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 real path, not shim) +2. `python` from PATH +3. `python3` from PATH + +### Setting up Python 3.13 + +If you're getting Python version errors, recreate your virtual environment: + +```bash +cd /home/nihal/Projects/forge + +# Remove old venv +rm -rf .env + +# Create new venv with Python 3.13 +python3.13 -m venv .env +# OR if pyenv is active with 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 Python 3.12. 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 `-q` flag for quiet mode. Server logs are automatically redirected to `/tmp/forge-cli-*/server.log`. + +### Guild won't launch + +1. Check Python version at startup (should show 3.13+) +2. Use `/status` to check if agents are running +3. Look at server logs: `/tmp/forge-cli-*/server.log` +4. Verify agent registry seeded: Look for "Seeding agent registry" at startup + +### Ctrl+C doesn't work + +This was fixed. Update to latest version and rebuild. + +## 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. + +### React Guild + +```bash +./forge guild run ../guilds/react_guild.json +``` + +### Custom Configuration + +```bash +./forge guild run \ + --backend redis \ + --org-id my-org \ + --user-id alice \ + --user-name "Alice Smith" \ + --python /home/nihal/.pyenv/versions/3.13.0/bin/python \ + -q \ + ../guilds/echo_app.json +``` + +## Development + +### Project Structure + +``` +forge-go/ +ā”œā”€ā”€ cli/ +│ ā”œā”€ā”€ guild_runtime.go # Embedded runtime +│ ā”œā”€ā”€ 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/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go new file mode 100644 index 0000000..0e00c28 --- /dev/null +++ b/forge-go/cli/guild_runtime.go @@ -0,0 +1,590 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/redis/go-redis/v9" + "github.com/rustic-ai/forge/forge-go/guild" + "github.com/rustic-ai/forge/forge-go/messaging" + "github.com/rustic-ai/forge/forge-go/protocol" + "gopkg.in/yaml.v3" +) + +// 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) +} + +// 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 + 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) + } + + embeddedRedisAddr, err := reserveLocalAddr() + if err != nil { + return fmt.Errorf("failed to reserve redis address: %w", err) + } + r.redisAddr = embeddedRedisAddr + + // 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, + "--embedded-redis-addr", embeddedRedisAddr, + "--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 r.config.NATSUrl != "" { + args = append(args, "--nats", r.config.NATSUrl) + } + + // Create server command + cmd := exec.Command(binPath, args...) + cmd.Dir = r.config.ForgeRoot + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + // 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 r.config.NATSUrl != "" { + env = append(env, "FORGE_EXTRA_DEPS=rusticai-nats") + } + + 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) + } + + // Create Redis client + r.redisClient = redis.NewClient(&redis.Options{Addr: embeddedRedisAddr}) + + slog.Info("Guild runtime started", "listen_addr", listenAddr, "backend", r.config.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]interface{} `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]interface{}{ + "qualified_class_name": className, + "agent_name": agent["id"], // API expects agent_name + "agent_doc": agent["description"], + "agent_props_schema": map[string]interface{}{}, // Empty schema for now + "message_handlers": map[string]interface{}{}, // Empty for now + } + + // 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) + var checker map[string]interface{} + 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 agent names from spec + agentsByName := make(map[string]string) + for _, agent := range spec.Agents { + agentsByName[agent.Name] = agent.Name + } + + // Try to match IDs to names (this is approximate since we don't have the actual mapping) + // Store what we know + for agentID := range statuses { + // Check if this is the manager agent + if agentID == guildID+"#manager_agent" { + continue + } + + // For now, try to find matching agent from spec by index + // In a real scenario, we'd need to query the guild API for the actual mapping + if len(spec.Agents) > 0 { + r.agentNames[agentID] = spec.Agents[0].Name + } + } +} + +// 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) { + 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 +} + +// 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 +func (r *GuildRuntime) getMessagingBackend() (messaging.Backend, error) { + // For now, we always use the embedded Redis backend + // The server internally handles NATS if configured + return messaging.NewRedisBackend(r.redisClient), nil +} + +// Shutdown gracefully shuts down the runtime +func (r *GuildRuntime) Shutdown() error { + slog.Info("Shutting down guild runtime") + + r.cancel() + + if r.redisClient != nil { + r.redisClient.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") +} + +func (r *GuildRuntime) createBlueprint(spec *protocol.GuildSpec) (map[string]interface{}, error) { + // Convert spec to blueprint format + blueprint := map[string]interface{}{ + "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]interface{} + 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]interface{}{ + "guild_name": guildName, + "user_id": r.config.UserID, + "org_id": r.config.OrgID, + } + + var result map[string]interface{} + 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 interface{}) 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 { + syscall.Kill(-r.serverCmd.Process.Pid, syscall.SIGKILL) + r.serverCmd.Wait() + } +} + +// 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/message_builder.go b/forge-go/cli/message_builder.go new file mode 100644 index 0000000..15936e7 --- /dev/null +++ b/forge-go/cli/message_builder.go @@ -0,0 +1,133 @@ +package cli + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/rustic-ai/forge/forge-go/protocol" +) + +var ( + idGen *protocol.GemstoneGenerator + idGenOnce sync.Once +) + +func getIDGen() *protocol.GemstoneGenerator { + idGenOnce.Do(func() { + // Use machine ID 1 for CLI + gen, _ := protocol.NewGemstoneGenerator(1) + idGen = gen + }) + return idGen +} + +// BuildChatMessage creates a chatCompletionRequest message +func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, error) { + gen := getIDGen() + 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]interface{}{ + "messages": []interface{}{ + map[string]interface{}{ + "role": "user", + "name": userID, + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": text, + }, + }, + }, + }, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + convID, _ := gen.Generate(protocol.PriorityNormal) + convIDInt := convID.ToInt() + + msg := protocol.NewMessageFromGemstoneID(msgID) + msg.Format = "chatCompletionRequest" + 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]interface{}, format string) (*protocol.Message, error) { + gen := getIDGen() + 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 := getIDGen() + msgID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate message ID: %w", err) + } + + payload := map[string]interface{}{ + "dummy": 1, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + convID, _ := gen.Generate(protocol.PriorityNormal) + 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 +} diff --git a/forge-go/cli/subscription.go b/forge-go/cli/subscription.go new file mode 100644 index 0000000..e4c6835 --- /dev/null +++ b/forge-go/cli/subscription.go @@ -0,0 +1,98 @@ +package cli + +import ( + "context" + "fmt" + + "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 +} + +// 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 +func (s *GuildSubscription) Close() error { + s.cancel() + close(s.msgChan) + close(s.errChan) + return s.sub.Close() +} + +func (s *GuildSubscription) forward() { + for { + select { + case <-s.ctx.Done(): + return + case subMsg, ok := <-s.sub.Channel(): + if !ok { + return + } + s.msgChan <- subMsg.Message + case err, ok := <-s.sub.ErrChannel(): + if !ok { + return + } + s.errChan <- err + } + } +} diff --git a/forge-go/command/guild.go b/forge-go/command/guild.go new file mode 100644 index 0000000..b9761f5 --- /dev/null +++ b/forge-go/command/guild.go @@ -0,0 +1,86 @@ +package command + +import ( + "github.com/spf13/cobra" +) + +var ( + guildBackend string + guildOrgID string + guildUserID string + guildUserName string + guildSupervisor string + guildPython 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().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..40b8665 --- /dev/null +++ b/forge-go/command/guild_inspect.go @@ -0,0 +1,138 @@ +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) + var checker map[string]interface{} + 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.Printf(" Transformer: present\n") + } + + fmt.Println() + } + } + + // Display dependencies + if spec.DependencyMap != nil && 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 spec.Properties != nil && 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_run.go b/forge-go/command/guild_run.go new file mode 100644 index 0000000..5fd2675 --- /dev/null +++ b/forge-go/command/guild_run.go @@ -0,0 +1,552 @@ +package command + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "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" +) + +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", "dependencies.yaml"), + AgentRegistry: filepath.Join(forgeRoot, "conf", "forge-agent-registry.yaml"), + ForgePythonPath: filepath.Join(forgeRepoRoot, "forge-python"), + SupervisorType: guildSupervisor, + PythonPath: pythonPath, + } + + 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 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.Printf("\nāœ… Guild launched successfully!\n") + fmt.Printf(" Guild ID: %s\n", guildID) + fmt.Println() + } + + // Wait a moment for agents to fully start + time.Sleep(2 * time.Second) + + // Determine the topic to send user messages to + // For guilds with routing rules (UserProxyAgent), use default_topic + // Otherwise use the first agent's topic (simple echo-style guilds) + userMessageTopic := "default_topic" + hasRoutingRules := spec.Routes != nil && len(spec.Routes.Steps) > 0 + + if !hasRoutingRules && len(spec.Agents) > 0 && len(spec.Agents[0].AdditionalTopics) > 0 { + // Simple guild without routing - send directly to first agent + userMessageTopic = spec.Agents[0].AdditionalTopics[0] + } + // Complex guilds with routing rules - keep default_topic + + // Show agent status + if !guildQuiet { + if err := showAgentStatus(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() + + if guildVerbose { + 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, sub, runtime, spec, 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": + showAgentStatus(runtime, guildID) + 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(runtime, guildID, config.UserID, config.UserName, line, userMessageTopic); err != nil { + fmt.Printf("āŒ Error sending message: %v\n", err) + } + } + } +} + +func showAgentStatus(runtime *cli.GuildRuntime, guildID string) error { + statuses, err := runtime.GetAgentStatuses(guildID) + if err != nil { + return err + } + + fmt.Println("\nšŸ¤– Agent Status:") + if len(statuses) == 0 { + fmt.Println(" 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.Printf(" %s %s (PID: %d) - %s\n", stateIcon, agentName, status.PID, status.State) + } else { + fmt.Printf(" %s %s (PID: %d) - %s\n", stateIcon, agentID, status.PID, status.State) + } + } + fmt.Println() + + return nil +} + +func sendChatMessage(runtime *cli.GuildRuntime, guildID, userID, userName, text, topic string) error { + msg, err := cli.BuildChatMessage(userID, userName, text, topic) + if err != nil { + return err + } + + fmt.Printf("šŸ“¤ Sending to topic: %s\n", topic) + if err := runtime.PublishMessage(guildID, topic, msg); err != nil { + return fmt.Errorf("failed to publish: %w", err) + } + + return nil +} + +func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, verbose, showRouting bool) { + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-sub.Messages(): + if !ok { + return + } + printMessage(msg, runtime, spec, verbose, showRouting) + case err, ok := <-sub.Errors(): + if !ok { + return + } + fmt.Printf("\nāŒ Subscription error: %v\n> ", err) + } + } +} + +func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, verbose, showRouting bool) { + // Debug: show all message formats in verbose mode only + if verbose { + topicsDebug := msg.Topics.ToSlice() + fmt.Printf("\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 + } + } + + timestamp := time.Unix(int64(msg.Timestamp), 0).Format("15:04:05") + topics := msg.Topics.ToSlice() + topicStr := strings.Join(topics, ", ") + + // Message header + fmt.Println("\n" + strings.Repeat("─", 70)) + fmt.Printf("šŸ“Ø [%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.Printf(" From: %s\n", senderName) + } else if senderID != "" { + // Fallback to ID if no name + fmt.Printf(" From: %s\n", senderID) + } + + // Message content + if len(msg.Payload) > 0 { + var payload map[string]interface{} + if err := json.Unmarshal(msg.Payload, &payload); err == nil { + // Pretty print payload + if verbose { + prettyPayload, _ := json.MarshalIndent(payload, " ", " ") + fmt.Printf(" 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"].([]interface{}); ok && len(messages) > 0 { + if firstMsg, ok := messages[0].(map[string]interface{}); ok { + if content, ok := firstMsg["content"].([]interface{}); ok && len(content) > 0 { + if textContent, ok := content[0].(map[string]interface{}); ok { + if text, ok := textContent["text"].(string); ok { + fmt.Printf(" šŸ’¬ %s\n", text) + displayed = true + } + } + } + } + } + + // Try chatCompletionResponse format (agent responses) + if !displayed { + if choices, ok := payload["choices"].([]interface{}); ok && len(choices) > 0 { + if choice, ok := choices[0].(map[string]interface{}); ok { + if message, ok := choice["message"].(map[string]interface{}); ok { + if content, ok := message["content"].(string); ok { + fmt.Printf(" šŸ’¬ %s\n", content) + displayed = true + } + } + } + } + } + + // Try simple text field + if !displayed { + if text, ok := payload["text"].(string); ok { + fmt.Printf(" šŸ’¬ %s\n", text) + displayed = true + } + } + + // Try content field directly (common in some formats) + if !displayed { + if content, ok := payload["content"].(string); ok { + fmt.Printf(" šŸ’¬ %s\n", content) + displayed = true + } + } + + // Generic payload display if nothing matched + if !displayed { + payloadBytes, _ := json.Marshal(payload) + if len(payloadBytes) > 150 { + fmt.Printf(" šŸ“„ %s...\n", string(payloadBytes[:150])) + } else { + fmt.Printf(" šŸ“„ %s\n", string(payloadBytes)) + } + } + } + } + } + + // Routing information + if showRouting && len(msg.MessageHistory) > 0 { + fmt.Println(" šŸ”€ 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.Printf(" %d. %s (%s)%s%s%s\n", + i+1, agent, processor, fromTopic, toTopics, reasonStr) + } + } + + if verbose && msg.RoutingSlip != nil { + fmt.Println(" šŸ“‹ Routing Slip:") + slipBytes, _ := json.MarshalIndent(msg.RoutingSlip, " ", " ") + fmt.Printf(" %s\n", string(slipBytes)) + } + + fmt.Print("> ") +} + +func findForgeRoot(startDir string) string { + 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 + } + 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_validate.go b/forge-go/command/guild_validate.go new file mode 100644 index 0000000..2ae2422 --- /dev/null +++ b/forge-go/command/guild_validate.go @@ -0,0 +1,113 @@ +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) + var checker map[string]interface{} + 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/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/conf/agent-dependencies.yaml b/forge-go/conf/agent-dependencies.yaml index 413344e..3bc684f 100644 --- a/forge-go/conf/agent-dependencies.yaml +++ b/forge-go/conf/agent-dependencies.yaml @@ -1,18 +1,16 @@ kvstore: class_name: rustic_ai.core.guild.agent_ext.depends.kvstore.InMemoryKVStoreResolver - provided_type: rustic_ai.core.kvstore.KVStore properties: {} filesystem: class_name: rustic_ai.core.guild.agent_ext.depends.filesystem.FileSystemResolver - provided_type: rustic_ai.core.filesystem.FileSystem properties: - path_base: /tmp + path_base: / protocol: file storage_options: auto_mkdir: true + asynchronous: true code_runner: class_name: rustic_ai.core.guild.agent_ext.depends.code_execution.stateless.InProcessCodeInterpreterResolver - provided_type: rustic_ai.core.code_execution.CodeRunner properties: {} embeddings: class_name: rustic_ai.langchain.agent_ext.embeddings.openai.OpenAIEmbeddingsResolver @@ -29,6 +27,9 @@ textsplitter: kb_backend: class_name: rustic_ai.core.knowledgebase.kbindex_backend_memory.InMemoryKBIndexBackendResolver properties: {} +uniko: + class_name: rustic_ai.uniko_agent.resolver.UnikoResolver + properties: {} # --------------------------------------------------------------------------- # LLM Dependencies @@ -38,59 +39,56 @@ kb_backend: llm: 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 + model: gpt-4.1 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 llm_openai: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver - provided_type: rustic_ai.core.llm.LLM properties: model: gpt-5.4 @@ -100,19 +98,18 @@ llm_openai: llm_anthropic_sonnet: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver properties: - model: anthropic/claude-sonnet-4-6-20260301 + model: claude-sonnet-4-6 llm_anthropic_opus: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver properties: - model: anthropic/claude-opus-4-6-20260301 + model: claude-opus-4-5 # --- Google Gemini (AI Studio) --- # Requires: GEMINI_API_KEY llm_gemini: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver - provided_type: rustic_ai.core.llm.LLM properties: model: gemini/gemini-3.1 @@ -123,8 +120,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 +131,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 +141,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/conf/forge-agent-registry.yaml b/forge-go/conf/forge-agent-registry.yaml index a132f8e..db91212 100644 --- a/forge-go/conf/forge-agent-registry.yaml +++ b/forge-go/conf/forge-agent-registry.yaml @@ -22,7 +22,7 @@ entries: class_name: rustic_ai.vertexai.agents.google_research_agent.GoogleResearchAgent description: Google Search grounding agent runtime: uvx - package: rusticai-vertexai + package: /home/nihal/Projects/ai-platform/rustic-ai/vertexai/dist/rusticai_vertexai-1.2.3-py3-none-any.whl secrets: - VERTEXAI_PROJECT - VERTEXAI_LOCATION @@ -30,7 +30,7 @@ entries: class_name: rustic_ai.vertexai.agents.content_moderation.ContentModerationAgent description: Vertex AI content moderation runtime: uvx - package: rusticai-vertexai + package: /home/nihal/Projects/ai-platform/rustic-ai/vertexai/dist/rusticai_vertexai-1.2.3-py3-none-any.whl with_dependencies: - pandas secrets: @@ -40,7 +40,7 @@ entries: class_name: rustic_ai.vertexai.agents.image_generation.VertexAiImagenAgent description: Vertex AI Imagen generation runtime: uvx - package: rusticai-vertexai + package: /home/nihal/Projects/ai-platform/rustic-ai/vertexai/dist/rusticai_vertexai-1.2.3-py3-none-any.whl secrets: - VERTEXAI_PROJECT - VERTEXAI_LOCATION @@ -54,6 +54,7 @@ entries: with_dependencies: - rusticai-litellm - rusticai-lancedb + - /home/nihal/Projects/ai-platform/rustic-ai/pandas-analyst/dist/rusticai_pandas_analyst-0.1.3-py3-none-any.whl secrets: - OPENAI_API_KEY - ANTHROPIC_API_KEY @@ -66,6 +67,7 @@ entries: with_dependencies: - rusticai-litellm - rusticai-lancedb + - /home/nihal/Projects/ai-platform/rustic-ai/pandas-analyst/dist/rusticai_pandas_analyst-0.1.3-py3-none-any.whl secrets: - OPENAI_API_KEY - ANTHROPIC_API_KEY @@ -175,132 +177,132 @@ entries: class_name: rustic_ai.demos.agents.bom_risk_explorer.ask_question_agent.AskQuestionAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: ComplianceScanAgent class_name: rustic_ai.demos.agents.contract_copilot.compliance_scan_agent.ComplianceScanAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: RedlineDiffAgent class_name: rustic_ai.demos.agents.contract_copilot.redline_diff_agent.RedlineDiffAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: CSVDataAgent class_name: rustic_ai.demos.agents.csv.csv_data_agent.CSVDataAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: ForecastingAgent class_name: rustic_ai.demos.agents.forecasting.prophet.ForecastingAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: BeautifulSoupAgent class_name: rustic_ai.demos.agents.html_parser.beautiful_soup_agent.BeautifulSoupAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: LifecycleScorerAgent class_name: rustic_ai.demos.agents.obsolescence_radar.lifecycle_scorer_agent.LifecycleScorerAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: ProductRiskCalculatorAgent class_name: rustic_ai.demos.agents.obsolescence_radar.product_risk_calculator_agent.ProductRiskCalculatorAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: ReplacementFinderAgent class_name: rustic_ai.demos.agents.obsolescence_radar.replacement_finder_agent.ReplacementFinderAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: OCRAgent class_name: rustic_ai.demos.agents.ocr.tesseract_agent.OCRAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: FileReaderWriterAgent class_name: rustic_ai.demos.agents.preprocessing.file_reader_writer_agent.FileReaderWriterAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: ImageAgent class_name: rustic_ai.demos.agents.preprocessing.image_agent.ImageAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: PandasAgent class_name: rustic_ai.demos.agents.preprocessing.pandas_agent.PandasAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: CPQAgent class_name: rustic_ai.demos.agents.quote_ai.cpq_integration_agent.CPQAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: OptimizerAgent class_name: rustic_ai.demos.agents.quote_ai.optimizer_agent.OptimizerAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: ReportGeneratorAgent class_name: rustic_ai.demos.agents.report_generator.report_agent.ReportGeneratorAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: HunterIOAgent class_name: rustic_ai.demos.agents.research.hunter_io_agent.HunterIOAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: RFQAssistManager class_name: rustic_ai.demos.agents.rfq_assist.manager.RFQAssistManager description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: ClockAgent class_name: rustic_ai.demos.agents.simulation.clock_agent.ClockAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: DynamicRoutingAgent class_name: rustic_ai.demos.agents.simulation.dynamic_router_agent.DynamicRoutingAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: StatusTrackerAgent class_name: rustic_ai.demos.agents.simulation.status_tracker_agent.StatusTrackerAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: StoryImageAgent class_name: rustic_ai.demos.agents.story_telling.image_agent.StoryImageAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: LiteLLMAgentWithContext class_name: rustic_ai.demos.agents.story_telling.lite_llm_agent_with_context.LiteLLMAgentWithContext description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: SyncMarvinAgent class_name: rustic_ai.demos.agents.story_telling.sync_classifier_agent.SyncMarvinAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: LineageBuilderAgent class_name: rustic_ai.demos.agents.traceability.lineage_builder_agent.LineageBuilderAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: VegaLiteAgent class_name: rustic_ai.demos.agents.viz.vegalite_agent.VegaLiteAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-demos + package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl - id: AutonomousCFO class_name: rustic_ai.fiscal_eye.data_analyst.autonomous_cfo.AutonomousCFO description: Auto-added from static agents snapshot @@ -360,4 +362,9 @@ entries: class_name: rustic_ai.simulation.agent.SimulationAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-simulation \ No newline at end of file + package: rusticai-simulation + - id: MemoryAgent + class_name: rustic_ai.uniko_agent.agent.MemoryAgent + description: Memory and knowledge management agent + runtime: uvx + package: /home/nihal/Projects/ai-platform/rustic-ai/uniko-agent/dist/rusticai_uniko_agent-0.0.1-py3-none-any.whl \ No newline at end of file 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/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..8caa54d --- /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 + } +} \ No newline at end of file diff --git a/guilds/uniko_research_guild.json b/guilds/uniko_research_guild.json new file mode 100644 index 0000000..1051b0f --- /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" + } + ] + } + } +} \ No newline at end of file From 3ebac2cf155da841d21d39d7c966779fa4dd62f8 Mon Sep 17 00:00:00 2001 From: Nihal-Srivastava05 Date: Thu, 9 Jul 2026 23:07:47 +0530 Subject: [PATCH 02/16] echo works --- forge-go/cli/message_builder.go | 71 +++++++++++++++++- forge-go/command/guild_run.go | 126 ++++++++++++++++++++++++++++---- 2 files changed, 180 insertions(+), 17 deletions(-) diff --git a/forge-go/cli/message_builder.go b/forge-go/cli/message_builder.go index 15936e7..19fc83b 100644 --- a/forge-go/cli/message_builder.go +++ b/forge-go/cli/message_builder.go @@ -55,7 +55,7 @@ func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, convIDInt := convID.ToInt() msg := protocol.NewMessageFromGemstoneID(msgID) - msg.Format = "chatCompletionRequest" + msg.Format = "chatCompletionRequest" // Use short format - gateway normalizes it msg.Sender = protocol.AgentTag{ ID: &userID, Name: &userName, @@ -131,3 +131,72 @@ func BuildHealthCheckMessage(userID, topic string) (*protocol.Message, error) { 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 := getIDGen() + + // 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 := getIDGen() + msgID, err := gen.Generate(protocol.PriorityNormal) + if err != nil { + return nil, fmt.Errorf("failed to generate message ID: %w", err) + } + + payload := map[string]interface{}{ + "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/command/guild_run.go b/forge-go/command/guild_run.go index 5fd2675..db37a10 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -108,16 +108,31 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { time.Sleep(2 * time.Second) // Determine the topic to send user messages to - // For guilds with routing rules (UserProxyAgent), use default_topic + // For guilds with routing rules, extract the destination from the first route // Otherwise use the first agent's topic (simple echo-style guilds) userMessageTopic := "default_topic" + useWrappedMessages := false hasRoutingRules := spec.Routes != nil && len(spec.Routes.Steps) > 0 - if !hasRoutingRules && len(spec.Agents) > 0 && len(spec.Agents[0].AdditionalTopics) > 0 { + if hasRoutingRules && len(spec.Routes.Steps) > 0 { + // Guild has routing - check if it uses UserProxyAgent + firstRoute := spec.Routes.Steps[0] + if firstRoute.AgentType != nil && *firstRoute.AgentType == "rustic_ai.core.agents.utils.user_proxy_agent.UserProxyAgent" { + // This guild uses UserProxyAgent - we need to create one and send wrapped messages + useWrappedMessages = true + // Messages go to user:{userID} and UserProxyAgent will route them + // according to the guild routing rules + } else if firstRoute.Destination != nil { + // Send directly to the route destination + topics := firstRoute.Destination.Topics.ToSlice() + if len(topics) > 0 { + userMessageTopic = topics[0] + } + } + } else if len(spec.Agents) > 0 && len(spec.Agents[0].AdditionalTopics) > 0 { // Simple guild without routing - send directly to first agent userMessageTopic = spec.Agents[0].AdditionalTopics[0] } - // Complex guilds with routing rules - keep default_topic // Show agent status if !guildQuiet { @@ -134,8 +149,54 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { } 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, "test-user") || 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 { - fmt.Printf(" Sending user messages to: %s\n\n", userMessageTopic) + 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 @@ -143,7 +204,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { defer cancel() // Start message display goroutine - go displayMessages(ctx, sub, runtime, spec, guildVerbose, guildShowRouting) + go displayMessages(ctx, sub, runtime, spec, config.UserID, guildVerbose, guildShowRouting) // Interactive REPL fmt.Println("\n" + strings.Repeat("=", 70)) @@ -224,7 +285,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { } // Send chat message - if err := sendChatMessage(runtime, guildID, config.UserID, config.UserName, line, userMessageTopic); err != nil { + if err := sendChatMessage(runtime, guildID, config.UserID, config.UserName, line, userMessageTopic, useWrappedMessages); err != nil { fmt.Printf("āŒ Error sending message: %v\n", err) } } @@ -269,21 +330,37 @@ func showAgentStatus(runtime *cli.GuildRuntime, guildID string) error { return nil } -func sendChatMessage(runtime *cli.GuildRuntime, guildID, userID, userName, text, topic string) error { - msg, err := cli.BuildChatMessage(userID, userName, text, topic) - if err != nil { - return err +func sendChatMessage(runtime *cli.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.Printf("šŸ“¤ Sending to topic: %s\n", topic) - if err := runtime.PublishMessage(guildID, topic, msg); err != nil { + fmt.Printf("šŸ“¤ Sending to topic: %s\n", actualTopic) + if err := runtime.PublishMessage(guildID, actualTopic, msg); err != nil { return fmt.Errorf("failed to publish: %w", err) } return nil } -func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, verbose, showRouting bool) { +func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, userID string, verbose, showRouting bool) { for { select { case <-ctx.Done(): @@ -292,7 +369,7 @@ func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *c if !ok { return } - printMessage(msg, runtime, spec, verbose, showRouting) + printMessage(msg, runtime, spec, userID, verbose, showRouting) case err, ok := <-sub.Errors(): if !ok { return @@ -302,7 +379,7 @@ func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *c } } -func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, verbose, showRouting bool) { +func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, userID string, verbose, showRouting bool) { // Debug: show all message formats in verbose mode only if verbose { topicsDebug := msg.Topics.ToSlice() @@ -331,10 +408,27 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protoc 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 + } } - timestamp := time.Unix(int64(msg.Timestamp), 0).Format("15:04:05") 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 + } + } + + timestamp := time.Unix(int64(msg.Timestamp), 0).Format("15:04:05") topicStr := strings.Join(topics, ", ") // Message header From 0cb625fea72c7f1d6c80e1048f750fefa0c9306e Mon Sep 17 00:00:00 2001 From: Nihal-Srivastava05 Date: Fri, 10 Jul 2026 00:18:36 +0530 Subject: [PATCH 03/16] feat: fix bugs --- forge-go/cli/message_builder.go | 2 +- forge-go/command/guild_run.go | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/forge-go/cli/message_builder.go b/forge-go/cli/message_builder.go index 19fc83b..4e8c96a 100644 --- a/forge-go/cli/message_builder.go +++ b/forge-go/cli/message_builder.go @@ -55,7 +55,7 @@ func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, convIDInt := convID.ToInt() msg := protocol.NewMessageFromGemstoneID(msgID) - msg.Format = "chatCompletionRequest" // Use short format - gateway normalizes it + 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, diff --git a/forge-go/command/guild_run.go b/forge-go/command/guild_run.go index db37a10..45bad48 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -352,7 +352,7 @@ func sendChatMessage(runtime *cli.GuildRuntime, guildID, userID, userName, text, actualTopic = topic } - fmt.Printf("šŸ“¤ Sending to topic: %s\n", actualTopic) + fmt.Printf("šŸ“¤ 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) } @@ -582,6 +582,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protoc } func findForgeRoot(startDir string) string { + // First try current directory and walk up dir := startDir for { goModPath := filepath.Join(dir, "go.mod") @@ -600,6 +601,21 @@ func findForgeRoot(startDir string) string { } 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 "" } From 19a5eb6c78a569476941b92db0ecd286496b78ad Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Thu, 9 Jul 2026 22:11:40 -0700 Subject: [PATCH 04/16] fix(cli): revert unintended config changes, keep required LiteLLM conf nesting The CLI PR bundled several config edits unrelated to (or harmful to) the CLI. Revert them while preserving the one change that is actually required: - forge-agent-registry.yaml: fully reverted. It had replaced published package names (rusticai-vertexai, rusticai-demos, ...) with developer-local wheel paths under /home/nihal/..., which break for every other user/CI, and added a MemoryAgent/uniko entry belonging to a separate feature. - agent-dependencies.yaml: - Restore provided_type on all resolvers. It is an optional field but a live join key: api/catalog.go skips entries with empty provided_type, so removing it silently empties blueprint dependency providers and the /dependencies/provided-type endpoints. - Restore path_base: /tmp (had been widened to /, exposing the whole root FS). - Revert unrelated default/model changes (llm default back to the local qwen starter; anthropic model ids and prefixes) and drop the stray asynchronous/uniko additions. - KEEP the conf: nesting for LiteLLM resolvers. LiteLLMResolver.__init__ only accepts (model, conf={}); connection settings must live under conf: or the resolver raises TypeError on instantiation. Added a note documenting this. - Remove repo-root .python-version (Go CLI scope; Python 3.13 is auto-detected). --- .python-version | 1 - forge-go/conf/agent-dependencies.yaml | 31 +++++++++--- forge-go/conf/forge-agent-registry.yaml | 67 +++++++++++-------------- 3 files changed, 53 insertions(+), 46 deletions(-) delete mode 100644 .python-version diff --git a/.python-version b/.python-version deleted file mode 100644 index 24ee5b1..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/forge-go/conf/agent-dependencies.yaml b/forge-go/conf/agent-dependencies.yaml index 3bc684f..6b2be63 100644 --- a/forge-go/conf/agent-dependencies.yaml +++ b/forge-go/conf/agent-dependencies.yaml @@ -1,16 +1,18 @@ kvstore: class_name: rustic_ai.core.guild.agent_ext.depends.kvstore.InMemoryKVStoreResolver + provided_type: rustic_ai.core.kvstore.KVStore properties: {} filesystem: class_name: rustic_ai.core.guild.agent_ext.depends.filesystem.FileSystemResolver + provided_type: rustic_ai.core.filesystem.FileSystem properties: - path_base: / + path_base: /tmp protocol: file storage_options: auto_mkdir: true - asynchronous: true code_runner: class_name: rustic_ai.core.guild.agent_ext.depends.code_execution.stateless.InProcessCodeInterpreterResolver + provided_type: rustic_ai.core.code_execution.CodeRunner properties: {} embeddings: class_name: rustic_ai.langchain.agent_ext.embeddings.openai.OpenAIEmbeddingsResolver @@ -27,23 +29,29 @@ textsplitter: kb_backend: class_name: rustic_ai.core.knowledgebase.kbindex_backend_memory.InMemoryKBIndexBackendResolver properties: {} -uniko: - class_name: rustic_ai.uniko_agent.resolver.UnikoResolver - properties: {} # --------------------------------------------------------------------------- # 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) --- llm: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver + provided_type: rustic_ai.core.llm.LLM properties: - model: gpt-4.1 + model: openai/rustic/qwen3.5-0.8b-starter + 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 conf: @@ -51,6 +59,7 @@ llm_local_qwen3_5_0_8b: 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 conf: @@ -58,6 +67,7 @@ llm_local_nomic_embed: 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 conf: @@ -65,6 +75,7 @@ llm_local_qwen3_5_2b: 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 conf: @@ -72,6 +83,7 @@ llm_local_qwen3_5_4b: 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 conf: @@ -79,6 +91,7 @@ llm_local_qwen3_4b_text: 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 conf: @@ -89,6 +102,7 @@ llm_local_gemma3_4b: llm_openai: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver + provided_type: rustic_ai.core.llm.LLM properties: model: gpt-5.4 @@ -98,18 +112,19 @@ llm_openai: llm_anthropic_sonnet: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver properties: - model: claude-sonnet-4-6 + model: anthropic/claude-sonnet-4-6-20260301 llm_anthropic_opus: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver properties: - model: claude-opus-4-5 + model: anthropic/claude-opus-4-6-20260301 # --- Google Gemini (AI Studio) --- # Requires: GEMINI_API_KEY llm_gemini: class_name: rustic_ai.litellm.agent_ext.llm.LiteLLMResolver + provided_type: rustic_ai.core.llm.LLM properties: model: gemini/gemini-3.1 diff --git a/forge-go/conf/forge-agent-registry.yaml b/forge-go/conf/forge-agent-registry.yaml index db91212..a132f8e 100644 --- a/forge-go/conf/forge-agent-registry.yaml +++ b/forge-go/conf/forge-agent-registry.yaml @@ -22,7 +22,7 @@ entries: class_name: rustic_ai.vertexai.agents.google_research_agent.GoogleResearchAgent description: Google Search grounding agent runtime: uvx - package: /home/nihal/Projects/ai-platform/rustic-ai/vertexai/dist/rusticai_vertexai-1.2.3-py3-none-any.whl + package: rusticai-vertexai secrets: - VERTEXAI_PROJECT - VERTEXAI_LOCATION @@ -30,7 +30,7 @@ entries: class_name: rustic_ai.vertexai.agents.content_moderation.ContentModerationAgent description: Vertex AI content moderation runtime: uvx - package: /home/nihal/Projects/ai-platform/rustic-ai/vertexai/dist/rusticai_vertexai-1.2.3-py3-none-any.whl + package: rusticai-vertexai with_dependencies: - pandas secrets: @@ -40,7 +40,7 @@ entries: class_name: rustic_ai.vertexai.agents.image_generation.VertexAiImagenAgent description: Vertex AI Imagen generation runtime: uvx - package: /home/nihal/Projects/ai-platform/rustic-ai/vertexai/dist/rusticai_vertexai-1.2.3-py3-none-any.whl + package: rusticai-vertexai secrets: - VERTEXAI_PROJECT - VERTEXAI_LOCATION @@ -54,7 +54,6 @@ entries: with_dependencies: - rusticai-litellm - rusticai-lancedb - - /home/nihal/Projects/ai-platform/rustic-ai/pandas-analyst/dist/rusticai_pandas_analyst-0.1.3-py3-none-any.whl secrets: - OPENAI_API_KEY - ANTHROPIC_API_KEY @@ -67,7 +66,6 @@ entries: with_dependencies: - rusticai-litellm - rusticai-lancedb - - /home/nihal/Projects/ai-platform/rustic-ai/pandas-analyst/dist/rusticai_pandas_analyst-0.1.3-py3-none-any.whl secrets: - OPENAI_API_KEY - ANTHROPIC_API_KEY @@ -177,132 +175,132 @@ entries: class_name: rustic_ai.demos.agents.bom_risk_explorer.ask_question_agent.AskQuestionAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: ComplianceScanAgent class_name: rustic_ai.demos.agents.contract_copilot.compliance_scan_agent.ComplianceScanAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: RedlineDiffAgent class_name: rustic_ai.demos.agents.contract_copilot.redline_diff_agent.RedlineDiffAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: CSVDataAgent class_name: rustic_ai.demos.agents.csv.csv_data_agent.CSVDataAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: ForecastingAgent class_name: rustic_ai.demos.agents.forecasting.prophet.ForecastingAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: BeautifulSoupAgent class_name: rustic_ai.demos.agents.html_parser.beautiful_soup_agent.BeautifulSoupAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: LifecycleScorerAgent class_name: rustic_ai.demos.agents.obsolescence_radar.lifecycle_scorer_agent.LifecycleScorerAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: ProductRiskCalculatorAgent class_name: rustic_ai.demos.agents.obsolescence_radar.product_risk_calculator_agent.ProductRiskCalculatorAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: ReplacementFinderAgent class_name: rustic_ai.demos.agents.obsolescence_radar.replacement_finder_agent.ReplacementFinderAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: OCRAgent class_name: rustic_ai.demos.agents.ocr.tesseract_agent.OCRAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: FileReaderWriterAgent class_name: rustic_ai.demos.agents.preprocessing.file_reader_writer_agent.FileReaderWriterAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: ImageAgent class_name: rustic_ai.demos.agents.preprocessing.image_agent.ImageAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: PandasAgent class_name: rustic_ai.demos.agents.preprocessing.pandas_agent.PandasAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: CPQAgent class_name: rustic_ai.demos.agents.quote_ai.cpq_integration_agent.CPQAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: OptimizerAgent class_name: rustic_ai.demos.agents.quote_ai.optimizer_agent.OptimizerAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: ReportGeneratorAgent class_name: rustic_ai.demos.agents.report_generator.report_agent.ReportGeneratorAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: HunterIOAgent class_name: rustic_ai.demos.agents.research.hunter_io_agent.HunterIOAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: RFQAssistManager class_name: rustic_ai.demos.agents.rfq_assist.manager.RFQAssistManager description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: ClockAgent class_name: rustic_ai.demos.agents.simulation.clock_agent.ClockAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: DynamicRoutingAgent class_name: rustic_ai.demos.agents.simulation.dynamic_router_agent.DynamicRoutingAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: StatusTrackerAgent class_name: rustic_ai.demos.agents.simulation.status_tracker_agent.StatusTrackerAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: StoryImageAgent class_name: rustic_ai.demos.agents.story_telling.image_agent.StoryImageAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: LiteLLMAgentWithContext class_name: rustic_ai.demos.agents.story_telling.lite_llm_agent_with_context.LiteLLMAgentWithContext description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: SyncMarvinAgent class_name: rustic_ai.demos.agents.story_telling.sync_classifier_agent.SyncMarvinAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: LineageBuilderAgent class_name: rustic_ai.demos.agents.traceability.lineage_builder_agent.LineageBuilderAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: VegaLiteAgent class_name: rustic_ai.demos.agents.viz.vegalite_agent.VegaLiteAgent description: Auto-added from static agents snapshot runtime: uvx - package: /home/nihal/Projects/ai-platform/demos/dist/rusticai_demos-0.0.1-py3-none-any.whl + package: rusticai-demos - id: AutonomousCFO class_name: rustic_ai.fiscal_eye.data_analyst.autonomous_cfo.AutonomousCFO description: Auto-added from static agents snapshot @@ -362,9 +360,4 @@ entries: class_name: rustic_ai.simulation.agent.SimulationAgent description: Auto-added from static agents snapshot runtime: uvx - package: rusticai-simulation - - id: MemoryAgent - class_name: rustic_ai.uniko_agent.agent.MemoryAgent - description: Memory and knowledge management agent - runtime: uvx - package: /home/nihal/Projects/ai-platform/rustic-ai/uniko-agent/dist/rusticai_uniko_agent-0.0.1-py3-none-any.whl \ No newline at end of file + package: rusticai-simulation \ No newline at end of file From 93a34693eba7570ad2c796cfb9abeeb86824eba4 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Thu, 9 Jul 2026 22:16:40 -0700 Subject: [PATCH 05/16] fix(cli): stop guild subscription Close from racing forward goroutine GuildSubscription.Close() called close(msgChan)/close(errChan) while the forward() goroutine could still be mid-send on those channels. Because forward's select can pick the source-channel case even after cancel(), it could execute `msgChan <- msg` on a just-closed channel and panic with "send on closed channel". The unguarded sends also meant a stalled consumer (buffer full) left the goroutine blocked forever, leaking it. Make forward() the sole closer of msgChan/errChan via defer, so a close can never race an in-flight send, and guard every send with ctx.Done() so Close() unblocks a stalled forward instead of leaking it. Close() now only cancels the context and closes the underlying subscription, and is idempotent via sync.Once. Adds subscription_test.go with race-tested coverage: concurrent Close during active forwarding (the panic regression), Close unblocking a stalled forward (the leak), and forward exiting when the source closes. The first test panics against the old Close() and passes against the fix. --- forge-go/cli/subscription.go | 46 ++++++--- forge-go/cli/subscription_test.go | 155 ++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 forge-go/cli/subscription_test.go diff --git a/forge-go/cli/subscription.go b/forge-go/cli/subscription.go index e4c6835..f36f654 100644 --- a/forge-go/cli/subscription.go +++ b/forge-go/cli/subscription.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "sync" "github.com/rustic-ai/forge/forge-go/messaging" "github.com/rustic-ai/forge/forge-go/protocol" @@ -10,11 +11,12 @@ import ( // 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 + 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 @@ -70,15 +72,27 @@ func (s *GuildSubscription) Errors() <-chan error { return s.errChan } -// Close closes the subscription +// 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 { - s.cancel() - close(s.msgChan) - close(s.errChan) - return s.sub.Close() + 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(): @@ -87,12 +101,20 @@ func (s *GuildSubscription) forward() { if !ok { return } - s.msgChan <- subMsg.Message + select { + case s.msgChan <- subMsg.Message: + case <-s.ctx.Done(): + return + } case err, ok := <-s.sub.ErrChannel(): if !ok { return } - s.errChan <- err + 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..33cddc7 --- /dev/null +++ b/forge-go/cli/subscription_test.go @@ -0,0 +1,155 @@ +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) + } +} From 5659ae257f60dfdda0303d7e7f8239be53f67927 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Thu, 9 Jul 2026 22:17:10 -0700 Subject: [PATCH 06/16] style(cli): gofmt subscription_test.go alignment --- forge-go/cli/subscription_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forge-go/cli/subscription_test.go b/forge-go/cli/subscription_test.go index 33cddc7..20e3d9b 100644 --- a/forge-go/cli/subscription_test.go +++ b/forge-go/cli/subscription_test.go @@ -30,8 +30,8 @@ func newFakeSubscription() *fakeSubscription { } 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 } +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() { From 8e44d7250fcfeae363abde5216fadcf531e68bf5 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Thu, 9 Jul 2026 22:21:10 -0700 Subject: [PATCH 07/16] fix(cli): correctness fixes in message building, name mapping, and display - message_builder: getIDGen swallowed the NewGemstoneGenerator error and left idGen nil, so any later Generate() would nil-panic. Return the error and propagate it from every Build* helper (all already return error). Also stop discarding the conversation-ID Generate() errors, which could silently produce a zero conversation ID on ErrClockMovedBackwards. - guild_run: msg.Timestamp is milliseconds (GemstoneID uses UnixMilli), but the REPL decoded it with time.Unix(sec, 0), rendering every timestamp ~55000 years in the future. Use time.UnixMilli. - guild_run: UserProxyAgent detection hardcoded "test-user" instead of the configured UserID, so --user-id never matched the name branch. Match config.UserID. - guild_run: --dependency-config pointed at conf/dependencies.yaml, which does not exist; the file is conf/agent-dependencies.yaml. Fix the path. - guild_runtime.buildAgentNameMap assigned spec.Agents[0].Name to every non-manager agent, mislabeling any multi-agent guild, and built an agentsByName map it never used. Match runtime IDs to spec names by name containment and only when the match is unique; drop the dead map. --- forge-go/cli/guild_runtime.go | 81 +++++++++++++++++---------------- forge-go/cli/message_builder.go | 45 +++++++++++++----- forge-go/command/guild_run.go | 8 ++-- 3 files changed, 80 insertions(+), 54 deletions(-) diff --git a/forge-go/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go index 0e00c28..c86f575 100644 --- a/forge-go/cli/guild_runtime.go +++ b/forge-go/cli/guild_runtime.go @@ -25,34 +25,34 @@ import ( // 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) + 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) } // 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 - tempDir string - dbPath string - dataDir string - ctx context.Context - cancel context.CancelFunc - agentNames map[string]string // Maps agent ID to agent name + config RuntimeConfig + serverCmd *exec.Cmd + serverBase string + rusticBase string + redisAddr string + redisClient *redis.Client + 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 @@ -259,7 +259,7 @@ func (r *GuildRuntime) seedAgentRegistry() error { // Convert to catalog agent entry format agentEntry := map[string]interface{}{ "qualified_class_name": className, - "agent_name": agent["id"], // API expects agent_name + "agent_name": agent["id"], // API expects agent_name "agent_doc": agent["description"], "agent_props_schema": map[string]interface{}{}, // Empty schema for now "message_handlers": map[string]interface{}{}, // Empty for now @@ -355,24 +355,27 @@ func (r *GuildRuntime) buildAgentNameMap(guildID string, spec *protocol.GuildSpe return } - // Map agent names from spec - agentsByName := make(map[string]string) - for _, agent := range spec.Agents { - agentsByName[agent.Name] = agent.Name - } - - // Try to match IDs to names (this is approximate since we don't have the actual mapping) - // Store what we know + // 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 { - // Check if this is the manager agent if agentID == guildID+"#manager_agent" { continue } - // For now, try to find matching agent from spec by index - // In a real scenario, we'd need to query the guild API for the actual mapping - if len(spec.Agents) > 0 { - r.agentNames[agentID] = spec.Agents[0].Name + 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 } } } @@ -503,7 +506,7 @@ func (r *GuildRuntime) createBlueprint(spec *protocol.GuildSpec) (map[string]int blueprint := map[string]interface{}{ "name": spec.Name, "description": spec.Description, - "spec": spec, // API expects "spec" not "guild_spec" + "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, diff --git a/forge-go/cli/message_builder.go b/forge-go/cli/message_builder.go index 4e8c96a..178ac29 100644 --- a/forge-go/cli/message_builder.go +++ b/forge-go/cli/message_builder.go @@ -10,21 +10,24 @@ import ( var ( idGen *protocol.GemstoneGenerator + idGenErr error idGenOnce sync.Once ) -func getIDGen() *protocol.GemstoneGenerator { +func getIDGen() (*protocol.GemstoneGenerator, error) { idGenOnce.Do(func() { // Use machine ID 1 for CLI - gen, _ := protocol.NewGemstoneGenerator(1) - idGen = gen + idGen, idGenErr = protocol.NewGemstoneGenerator(1) }) - return idGen + return idGen, idGenErr } // BuildChatMessage creates a chatCompletionRequest message func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, error) { - gen := getIDGen() + 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) @@ -51,11 +54,14 @@ func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, return nil, fmt.Errorf("failed to marshal payload: %w", err) } - convID, _ := gen.Generate(protocol.PriorityNormal) + 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.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, @@ -69,7 +75,10 @@ func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, // BuildSystemMessage creates a system message func BuildSystemMessage(topic string, payload map[string]interface{}, format string) (*protocol.Message, error) { - gen := getIDGen() + 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) @@ -103,7 +112,10 @@ func ParseMessageFromJSON(data []byte) (*protocol.Message, error) { // BuildHealthCheckMessage creates a health check message func BuildHealthCheckMessage(userID, topic string) (*protocol.Message, error) { - gen := getIDGen() + 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) @@ -117,7 +129,10 @@ func BuildHealthCheckMessage(userID, topic string) (*protocol.Message, error) { return nil, fmt.Errorf("failed to marshal payload: %w", err) } - convID, _ := gen.Generate(protocol.PriorityNormal) + 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) @@ -135,7 +150,10 @@ func BuildHealthCheckMessage(userID, topic string) (*protocol.Message, error) { // 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 := getIDGen() + 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") @@ -173,7 +191,10 @@ func BuildWrappedChatMessage(userID, userName, text string) (*protocol.Message, // 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 := getIDGen() + 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) diff --git a/forge-go/command/guild_run.go b/forge-go/command/guild_run.go index 45bad48..f84b03f 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -48,7 +48,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { UserID: guildUserID, UserName: guildUserName, ForgeRoot: forgeRepoRoot, - DependencyConfig: filepath.Join(forgeRoot, "conf", "dependencies.yaml"), + 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, @@ -173,7 +173,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { if err == nil { for agentID := range statuses { agentName := runtime.GetAgentName(agentID) - if strings.Contains(agentName, "test-user") || strings.Contains(agentID, "upa-") { + if strings.Contains(agentName, config.UserID) || strings.Contains(agentID, "upa-") { userProxyCreated = true if guildVerbose { fmt.Printf(" UserProxyAgent created: %s\n", agentName) @@ -428,7 +428,9 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protoc } } - timestamp := time.Unix(int64(msg.Timestamp), 0).Format("15:04:05") + // 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 From 6c4d8937d2a2eec6282185d4ae0eb758e6bf6747 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Fri, 10 Jul 2026 00:23:49 -0700 Subject: [PATCH 08/16] style(cli): satisfy repo linters and relocate CLI docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the linters enabled in .golangci-lint.yml (gofmt, gosimple, errcheck, unparam, ...) for the new guild CLI: - errcheck: handle previously-ignored errors — log the syscall.Kill/Wait failures in kill(), report showAgentStatus errors from the /status command, and make the best-effort blueprint-sniff json.Unmarshal ignores explicit (_ =) with a comment in guild_run/inspect/validate and LoadGuild. - unparam: drop the unused spec *protocol.GuildSpec parameter that was threaded through displayMessages into printMessage but never read. - gosimple (S1009): drop redundant nil checks before len() on maps in guild_inspect; convert two no-argument fmt.Printf calls to Print/Println. - gofmt: fix the misaligned guildBackend/... var block and replace interface{} with any across the new files. Docs: move GUILD_CLI_README.md out of the repo root (which has no other .md docs) into forge-go/initial-docs/guild-cli.md, alongside the existing forge-go design doc, and strip developer-local paths (/home/nihal/...), stale "this was fixed" notes, and the incorrect Go 1.21 prerequisite (module targets 1.25). Note: go.mod still carries unused charmbracelet/bubbletea indirect deps from an abandoned TUI experiment; `go mod tidy` should be run in a networked environment to drop them (could not run offline here). --- GUILD_CLI_README.md => docs/guild-cli.md | 151 +++++++++++------------ forge-go/cli/guild_runtime.go | 39 +++--- forge-go/cli/message_builder.go | 16 +-- forge-go/command/guild.go | 18 +-- forge-go/command/guild_inspect.go | 13 +- forge-go/command/guild_run.go | 30 ++--- forge-go/command/guild_validate.go | 7 +- 7 files changed, 141 insertions(+), 133 deletions(-) rename GUILD_CLI_README.md => docs/guild-cli.md (61%) diff --git a/GUILD_CLI_README.md b/docs/guild-cli.md similarity index 61% rename from GUILD_CLI_README.md rename to docs/guild-cli.md index 377cc13..c1b4944 100644 --- a/GUILD_CLI_README.md +++ b/docs/guild-cli.md @@ -1,30 +1,32 @@ # Forge Guild CLI -A command-line interface for running and debugging Forge guilds locally without the rustic-ui frontend. +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 +- 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.21+ (for building) +- Go 1.25+ (for building; see `forge-go/go.mod`) - Redis or NATS backend ## Installation +From the `forge-go` directory: + ```bash -cd /home/nihal/Projects/forge/forge-go -go build -o forge . +go build -o forge ./cmd/forge # Optionally install to PATH sudo cp forge /usr/local/bin/ ``` @@ -52,7 +54,7 @@ sudo cp forge /usr/local/bin/ # Basic usage ./forge guild run ../guilds/echo_app.json -# With custom Python +# With a specific Python interpreter ./forge guild run --python /path/to/python3.13 ../guilds/echo_app.json # Quiet mode (minimal startup output, clean display) @@ -113,38 +115,37 @@ Once the REPL starts, you can use: The CLI automatically filters noisy internal messages: **Shown by default:** -- āœ… User chat messages -- āœ… Agent responses -- āœ… Errors and warnings -- āœ… Important state changes +- 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 +- 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 real path, not shim) -2. `python` from PATH -3. `python3` from PATH +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're getting Python version errors, recreate your virtual environment: +If you hit a Python version error, recreate your virtual environment with +Python 3.13. From the repository root: ```bash -cd /home/nihal/Projects/forge - -# Remove old venv +# Remove the old venv rm -rf .env -# Create new venv with Python 3.13 +# Create a new venv with Python 3.13 python3.13 -m venv .env -# OR if pyenv is active with 3.13: +# ...or, if pyenv already resolves to 3.13: python -m venv .env # Activate and install @@ -156,26 +157,26 @@ pip install -e ./forge-python ### "Python 3.12 does not satisfy Python>=3.13" -Your virtual environment was created with Python 3.12. See "Setting up Python 3.13" above. +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. +Run the CLI from the `forge-go` directory or any subdirectory of the forge +repository. ### Server logs cluttering output -Use `-q` flag for quiet mode. Server logs are automatically redirected to `/tmp/forge-cli-*/server.log`. +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 Python version at startup (should show 3.13+) -2. Use `/status` to check if agents are running -3. Look at server logs: `/tmp/forge-cli-*/server.log` -4. Verify agent registry seeded: Look for "Seeding agent registry" at startup - -### Ctrl+C doesn't work - -This was fixed. Update to latest version and rebuild. +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 @@ -187,12 +188,6 @@ This was fixed. Update to latest version and rebuild. Type messages and see them echoed back by the agent. -### React Guild - -```bash -./forge guild run ../guilds/react_guild.json -``` - ### Custom Configuration ```bash @@ -201,7 +196,7 @@ Type messages and see them echoed back by the agent. --org-id my-org \ --user-id alice \ --user-name "Alice Smith" \ - --python /home/nihal/.pyenv/versions/3.13.0/bin/python \ + --python "$(pyenv which python)" \ -q \ ../guilds/echo_app.json ``` @@ -213,47 +208,47 @@ Type messages and see them echoed back by the agent. ``` forge-go/ ā”œā”€ā”€ cli/ -│ ā”œā”€ā”€ guild_runtime.go # Embedded runtime +│ ā”œā”€ā”€ 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 +│ ā”œā”€ā”€ 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 │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +│ 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 │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ GuildRuntime (guild_runtime.go) │ +│ - Embedded forge server │ +│ - Agent registry seeding │ +│ - Guild lifecycle management │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ -ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” -│ Forge Server (embedded) │ -│ - Redis/NATS messaging │ -│ - Agent supervision │ -│ - Guild management API │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Forge Server (embedded) │ +│ - Redis/NATS messaging │ +│ - Agent supervision │ +│ - Guild management API │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ -ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” -│ Python Agents (Python 3.13+) │ -│ - Guild manager agent │ -│ - User-defined agents │ -│ - Message processing │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Python Agents (Python 3.13+) │ +│ - Guild manager agent │ +│ - User-defined agents │ +│ - Message processing │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ``` ## License diff --git a/forge-go/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go index c86f575..d70b93a 100644 --- a/forge-go/cli/guild_runtime.go +++ b/forge-go/cli/guild_runtime.go @@ -241,7 +241,7 @@ func (r *GuildRuntime) seedAgentRegistry() error { } var registry struct { - Entries []map[string]interface{} `yaml:"entries"` + Entries []map[string]any `yaml:"entries"` } if err := yaml.Unmarshal(content, ®istry); err != nil { return fmt.Errorf("failed to parse registry: %w", err) @@ -257,12 +257,12 @@ func (r *GuildRuntime) seedAgentRegistry() error { } // Convert to catalog agent entry format - agentEntry := map[string]interface{}{ + 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]interface{}{}, // Empty schema for now - "message_handlers": map[string]interface{}{}, // Empty for now + "agent_props_schema": map[string]any{}, + "message_handlers": map[string]any{}, } // POST to /rustic/catalog/agents @@ -285,9 +285,11 @@ func (r *GuildRuntime) LoadGuild(specPath string) (*protocol.GuildSpec, error) { return nil, fmt.Errorf("failed to read file: %w", err) } - // Check if this is a blueprint wrapper (has a "spec" field) - var checker map[string]interface{} - json.Unmarshal(content, &checker) + // 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 @@ -501,9 +503,9 @@ func (r *GuildRuntime) waitForGuildRunning(guildID string, timeout time.Duration return fmt.Errorf("timeout waiting for guild to be running") } -func (r *GuildRuntime) createBlueprint(spec *protocol.GuildSpec) (map[string]interface{}, error) { +func (r *GuildRuntime) createBlueprint(spec *protocol.GuildSpec) (map[string]any, error) { // Convert spec to blueprint format - blueprint := map[string]interface{}{ + blueprint := map[string]any{ "name": spec.Name, "description": spec.Description, "spec": spec, // API expects "spec" not "guild_spec" @@ -512,7 +514,7 @@ func (r *GuildRuntime) createBlueprint(spec *protocol.GuildSpec) (map[string]int "organization_id": r.config.OrgID, } - var result map[string]interface{} + var result map[string]any if err := r.postJSON(r.rusticBase+"/catalog/blueprints/", blueprint, &result); err != nil { return nil, err } @@ -520,13 +522,13 @@ func (r *GuildRuntime) createBlueprint(spec *protocol.GuildSpec) (map[string]int } func (r *GuildRuntime) launchFromBlueprint(blueprintID, guildName string) (string, error) { - launchReq := map[string]interface{}{ + launchReq := map[string]any{ "guild_name": guildName, "user_id": r.config.UserID, "org_id": r.config.OrgID, } - var result map[string]interface{} + 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 @@ -540,7 +542,7 @@ func (r *GuildRuntime) launchFromBlueprint(blueprintID, guildName string) (strin return guildID, nil } -func (r *GuildRuntime) postJSON(url string, payload, result interface{}) error { +func (r *GuildRuntime) postJSON(url string, payload, result any) error { data, err := json.Marshal(payload) if err != nil { return err @@ -576,8 +578,15 @@ func (r *GuildRuntime) postJSON(url string, payload, result interface{}) error { func (r *GuildRuntime) kill() { if r.serverCmd != nil && r.serverCmd.Process != nil { - syscall.Kill(-r.serverCmd.Process.Pid, syscall.SIGKILL) - r.serverCmd.Wait() + // SIGKILL the whole process group (the server is started with Setpgid). + // Both calls are best-effort during teardown: log failures rather than + // propagate, since there is nothing left to recover. + if err := syscall.Kill(-r.serverCmd.Process.Pid, syscall.SIGKILL); err != nil { + slog.Debug("failed to signal server process group", "error", err) + } + if err := r.serverCmd.Wait(); err != nil { + slog.Debug("server process exited with error", "error", err) + } } } diff --git a/forge-go/cli/message_builder.go b/forge-go/cli/message_builder.go index 178ac29..e185bee 100644 --- a/forge-go/cli/message_builder.go +++ b/forge-go/cli/message_builder.go @@ -34,13 +34,13 @@ func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, } // Create chat completion request payload - payload := map[string]interface{}{ - "messages": []interface{}{ - map[string]interface{}{ + payload := map[string]any{ + "messages": []any{ + map[string]any{ "role": "user", "name": userID, - "content": []interface{}{ - map[string]interface{}{ + "content": []any{ + map[string]any{ "type": "text", "text": text, }, @@ -74,7 +74,7 @@ func BuildChatMessage(userID, userName, text, topic string) (*protocol.Message, } // BuildSystemMessage creates a system message -func BuildSystemMessage(topic string, payload map[string]interface{}, format string) (*protocol.Message, error) { +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) @@ -121,7 +121,7 @@ func BuildHealthCheckMessage(userID, topic string) (*protocol.Message, error) { return nil, fmt.Errorf("failed to generate message ID: %w", err) } - payload := map[string]interface{}{ + payload := map[string]any{ "dummy": 1, } payloadBytes, err := json.Marshal(payload) @@ -200,7 +200,7 @@ func BuildUserProxyCreationRequest(userID, userName string) (*protocol.Message, return nil, fmt.Errorf("failed to generate message ID: %w", err) } - payload := map[string]interface{}{ + payload := map[string]any{ "user_id": userID, "user_name": userName, } diff --git a/forge-go/command/guild.go b/forge-go/command/guild.go index b9761f5..6a86249 100644 --- a/forge-go/command/guild.go +++ b/forge-go/command/guild.go @@ -5,15 +5,15 @@ import ( ) var ( - guildBackend string - guildOrgID string - guildUserID string - guildUserName string - guildSupervisor string - guildPython string - guildVerbose bool - guildShowRouting bool - guildQuiet bool + guildBackend string + guildOrgID string + guildUserID string + guildUserName string + guildSupervisor string + guildPython string + guildVerbose bool + guildShowRouting bool + guildQuiet bool ) // GuildCmd is the parent command for guild-related operations diff --git a/forge-go/command/guild_inspect.go b/forge-go/command/guild_inspect.go index 40b8665..96a243f 100644 --- a/forge-go/command/guild_inspect.go +++ b/forge-go/command/guild_inspect.go @@ -21,9 +21,10 @@ func inspectGuild(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to read file: %w", err) } - // Check if this is a blueprint wrapper (has a "spec" field) - var checker map[string]interface{} - json.Unmarshal(content, &checker) + // 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 { @@ -110,7 +111,7 @@ func inspectGuild(cmd *cobra.Command, args []string) error { } if rule.Transformer != nil { - fmt.Printf(" Transformer: present\n") + fmt.Println(" Transformer: present") } fmt.Println() @@ -118,7 +119,7 @@ func inspectGuild(cmd *cobra.Command, args []string) error { } // Display dependencies - if spec.DependencyMap != nil && len(spec.DependencyMap) > 0 { + 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) @@ -127,7 +128,7 @@ func inspectGuild(cmd *cobra.Command, args []string) error { } // Display properties - if spec.Properties != nil && len(spec.Properties) > 0 { + if len(spec.Properties) > 0 { fmt.Println("Properties:") propsJSON, _ := json.MarshalIndent(spec.Properties, " ", " ") fmt.Printf(" %s\n", string(propsJSON)) diff --git a/forge-go/command/guild_run.go b/forge-go/command/guild_run.go index f84b03f..8df4e05 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -99,7 +99,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { } if !guildQuiet { - fmt.Printf("\nāœ… Guild launched successfully!\n") + fmt.Print("\nāœ… Guild launched successfully!\n") fmt.Printf(" Guild ID: %s\n", guildID) fmt.Println() } @@ -204,7 +204,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { defer cancel() // Start message display goroutine - go displayMessages(ctx, sub, runtime, spec, config.UserID, guildVerbose, guildShowRouting) + go displayMessages(ctx, sub, runtime, config.UserID, guildVerbose, guildShowRouting) // Interactive REPL fmt.Println("\n" + strings.Repeat("=", 70)) @@ -268,7 +268,9 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { fmt.Println("šŸ‘‹ Goodbye!") return nil case "/status": - showAgentStatus(runtime, guildID) + if err := showAgentStatus(runtime, guildID); err != nil { + fmt.Printf("āš ļø Warning: could not get agent status: %v\n", err) + } continue case "/help": fmt.Println("\nCommands:") @@ -360,7 +362,7 @@ func sendChatMessage(runtime *cli.GuildRuntime, guildID, userID, userName, text, return nil } -func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, userID string, verbose, showRouting bool) { +func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *cli.GuildRuntime, userID string, verbose, showRouting bool) { for { select { case <-ctx.Done(): @@ -369,7 +371,7 @@ func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *c if !ok { return } - printMessage(msg, runtime, spec, userID, verbose, showRouting) + printMessage(msg, runtime, userID, verbose, showRouting) case err, ok := <-sub.Errors(): if !ok { return @@ -379,7 +381,7 @@ func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *c } } -func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protocol.GuildSpec, userID string, verbose, showRouting bool) { +func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID string, verbose, showRouting bool) { // Debug: show all message formats in verbose mode only if verbose { topicsDebug := msg.Topics.ToSlice() @@ -461,7 +463,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protoc // Message content if len(msg.Payload) > 0 { - var payload map[string]interface{} + var payload map[string]any if err := json.Unmarshal(msg.Payload, &payload); err == nil { // Pretty print payload if verbose { @@ -472,10 +474,10 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protoc displayed := false // Try chatCompletionRequest format (user messages) - if messages, ok := payload["messages"].([]interface{}); ok && len(messages) > 0 { - if firstMsg, ok := messages[0].(map[string]interface{}); ok { - if content, ok := firstMsg["content"].([]interface{}); ok && len(content) > 0 { - if textContent, ok := content[0].(map[string]interface{}); ok { + 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.Printf(" šŸ’¬ %s\n", text) displayed = true @@ -487,9 +489,9 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, spec *protoc // Try chatCompletionResponse format (agent responses) if !displayed { - if choices, ok := payload["choices"].([]interface{}); ok && len(choices) > 0 { - if choice, ok := choices[0].(map[string]interface{}); ok { - if message, ok := choice["message"].(map[string]interface{}); ok { + 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.Printf(" šŸ’¬ %s\n", content) displayed = true diff --git a/forge-go/command/guild_validate.go b/forge-go/command/guild_validate.go index 2ae2422..f5e35d8 100644 --- a/forge-go/command/guild_validate.go +++ b/forge-go/command/guild_validate.go @@ -22,9 +22,10 @@ func validateGuild(cmd *cobra.Command, args []string) error { return err } - // Check if this is a blueprint wrapper (has a "spec" field) - var checker map[string]interface{} - json.Unmarshal(content, &checker) + // 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 { From 7a8559cbe801527fcc8948e35f4208d7f372f77c Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Fri, 10 Jul 2026 00:26:39 -0700 Subject: [PATCH 09/16] test(cli): cover LoadGuild and findForgeRoot; fix fixture newlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guild_runtime_test.go: table of LoadGuild cases — direct JSON spec, blueprint wrapper unwrapping (the "spec" nesting), YAML falling through the JSON sniff to the extension parser, missing file, and unsupported extension. - guild_run_test.go: findForgeRoot walking up to the forge-go module, finding it in a forge-go/ subdir from the repo root, ignoring an unrelated module, and returning "" when absent. - guilds/react_guild.json, guilds/uniko_research_guild.json: add the missing trailing newline. --- forge-go/cli/guild_runtime_test.go | 75 ++++++++++++++++++++++++++++++ forge-go/command/guild_run_test.go | 58 +++++++++++++++++++++++ guilds/react_guild.json | 2 +- guilds/uniko_research_guild.json | 2 +- 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 forge-go/cli/guild_runtime_test.go create mode 100644 forge-go/command/guild_run_test.go 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/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/guilds/react_guild.json b/guilds/react_guild.json index 8caa54d..f6b8403 100644 --- a/guilds/react_guild.json +++ b/guilds/react_guild.json @@ -199,4 +199,4 @@ }, "gateway": null } -} \ No newline at end of file +} diff --git a/guilds/uniko_research_guild.json b/guilds/uniko_research_guild.json index 1051b0f..7cbfa0a 100644 --- a/guilds/uniko_research_guild.json +++ b/guilds/uniko_research_guild.json @@ -476,4 +476,4 @@ ] } } -} \ No newline at end of file +} From 4e2eb87eebf20c439f018a30d6ce3ea73add365b Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Fri, 10 Jul 2026 01:19:54 -0700 Subject: [PATCH 10/16] test(cli): raise cli & command coverage to 70% / 64% The guild CLI landed with almost no tests (cli 8.3% / command 14.3%), including the message-builder id-generator fix and the buildAgentNameMap fix, both at 0%. Add deterministic unit tests, backed by miniredis + httptest, plus small same-package refactors that make the command helpers testable. Refactor (command/guild_run.go, internal only, no external API change): - Thread an io.Writer through printMessage/showAgentStatus/sendChatMessage/ displayMessages so output can be captured with a bytes.Buffer. - Add unexported guildRuntime and messageSource interfaces so the helpers can run against a fake; *cli.GuildRuntime and *cli.GuildSubscription satisfy them. - Extract selectUserMessageTopic from runGuildREPL as a pure function. cli tests (now 70.7%): all message builders + ParseMessageFromJSON; miniredis coverage of GetAgentStatuses, buildAgentNameMap (the fix: unique-substring match, ambiguous falls back to raw ID), waitForGuildRunning, PublishMessage, Subscribe; httptest coverage of postJSON, createBlueprint, launchFromBlueprint, LaunchGuild, seedAgentRegistry, waitForReady; plus NewGuildRuntime, reserveLocalAddr, Shutdown. Start()/kill() are left to integration (real process). command tests (now 64.0%): printMessage (all skip branches, payload formats, truncation, sender resolution, verbose, routing history), validateGuild (all rules + blueprint + errors), inspectGuild (all sections + blueprint + errors), selectUserMessageTopic, detectPython (fake interpreters on PATH), and the io.Writer/fake-runtime helpers. runGuildREPL's server-driven body stays uncovered (needs a real forge binary); its early error path is exercised. --- forge-go/cli/guild_runtime_methods_test.go | 329 +++++++++++++++++++++ forge-go/cli/message_builder_test.go | 203 +++++++++++++ forge-go/cli/subscription_test.go | 18 ++ forge-go/cli/testhelpers_test.go | 67 +++++ forge-go/command/guild_inspect_test.go | 91 ++++++ forge-go/command/guild_run.go | 140 +++++---- forge-go/command/guild_run_helpers_test.go | 233 +++++++++++++++ forge-go/command/guild_validate_test.go | 109 +++++++ forge-go/command/printmessage_test.go | 134 +++++++++ forge-go/command/testhelpers_test.go | 97 ++++++ 10 files changed, 1362 insertions(+), 59 deletions(-) create mode 100644 forge-go/cli/guild_runtime_methods_test.go create mode 100644 forge-go/cli/message_builder_test.go create mode 100644 forge-go/cli/testhelpers_test.go create mode 100644 forge-go/command/guild_inspect_test.go create mode 100644 forge-go/command/guild_run_helpers_test.go create mode 100644 forge-go/command/guild_validate_test.go create mode 100644 forge-go/command/printmessage_test.go create mode 100644 forge-go/command/testhelpers_test.go 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..9b5ffc7 --- /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" + mr.Set(statusKey(guildID, "g1#manager_agent"), `{"state":"running","pid":42}`) + mr.Set(statusKey(guildID, "EchoAgent-abc"), `{"state":"starting","pid":7}`) + mr.Set(statusKey(guildID, "broken"), `not-json`) + // A key for a different guild must not be returned. + mr.Set(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"}}} + mr.Set(statusKey(guildID, guildID+"#manager_agent"), `{"state":"running","pid":1}`) + mr.Set(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"}}} + mr.Set(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" + mr.Set(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. + mr.Set(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/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/subscription_test.go b/forge-go/cli/subscription_test.go index 20e3d9b..ba052f4 100644 --- a/forge-go/cli/subscription_test.go +++ b/forge-go/cli/subscription_test.go @@ -153,3 +153,21 @@ func waitClosed(t *testing.T, done <-chan struct{}, msg string) { 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..f01e795 --- /dev/null +++ b/forge-go/cli/testhelpers_test.go @@ -0,0 +1,67 @@ +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 +} 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 index 8df4e05..d3bed29 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "os/exec" "os/signal" @@ -18,6 +19,22 @@ import ( "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] @@ -107,36 +124,12 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { // Wait a moment for agents to fully start time.Sleep(2 * time.Second) - // Determine the topic to send user messages to - // For guilds with routing rules, extract the destination from the first route - // Otherwise use the first agent's topic (simple echo-style guilds) - userMessageTopic := "default_topic" - useWrappedMessages := false - hasRoutingRules := spec.Routes != nil && len(spec.Routes.Steps) > 0 - - if hasRoutingRules && len(spec.Routes.Steps) > 0 { - // Guild has routing - check if it uses UserProxyAgent - firstRoute := spec.Routes.Steps[0] - if firstRoute.AgentType != nil && *firstRoute.AgentType == "rustic_ai.core.agents.utils.user_proxy_agent.UserProxyAgent" { - // This guild uses UserProxyAgent - we need to create one and send wrapped messages - useWrappedMessages = true - // Messages go to user:{userID} and UserProxyAgent will route them - // according to the guild routing rules - } else if firstRoute.Destination != nil { - // Send directly to the route destination - topics := firstRoute.Destination.Topics.ToSlice() - if len(topics) > 0 { - userMessageTopic = topics[0] - } - } - } else if len(spec.Agents) > 0 && len(spec.Agents[0].AdditionalTopics) > 0 { - // Simple guild without routing - send directly to first agent - userMessageTopic = spec.Agents[0].AdditionalTopics[0] - } + // Determine the topic to send user messages to. + userMessageTopic, useWrappedMessages := selectUserMessageTopic(spec) // Show agent status if !guildQuiet { - if err := showAgentStatus(runtime, guildID); err != nil { + if err := showAgentStatus(os.Stdout, runtime, guildID); err != nil { fmt.Printf("āš ļø Warning: could not get agent status: %v\n", err) } @@ -204,7 +197,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { defer cancel() // Start message display goroutine - go displayMessages(ctx, sub, runtime, config.UserID, guildVerbose, guildShowRouting) + go displayMessages(ctx, os.Stdout, sub, runtime, config.UserID, guildVerbose, guildShowRouting) // Interactive REPL fmt.Println("\n" + strings.Repeat("=", 70)) @@ -268,7 +261,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { fmt.Println("šŸ‘‹ Goodbye!") return nil case "/status": - if err := showAgentStatus(runtime, guildID); err != nil { + if err := showAgentStatus(os.Stdout, runtime, guildID); err != nil { fmt.Printf("āš ļø Warning: could not get agent status: %v\n", err) } continue @@ -287,22 +280,22 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { } // Send chat message - if err := sendChatMessage(runtime, guildID, config.UserID, config.UserName, line, userMessageTopic, useWrappedMessages); err != nil { + 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(runtime *cli.GuildRuntime, guildID string) error { +func showAgentStatus(w io.Writer, runtime guildRuntime, guildID string) error { statuses, err := runtime.GetAgentStatuses(guildID) if err != nil { return err } - fmt.Println("\nšŸ¤– Agent Status:") + fmt.Fprintln(w, "\nšŸ¤– Agent Status:") if len(statuses) == 0 { - fmt.Println(" No agents found") + fmt.Fprintln(w, " No agents found") return nil } @@ -322,17 +315,46 @@ func showAgentStatus(runtime *cli.GuildRuntime, guildID string) error { // Get agent display name agentName := runtime.GetAgentName(agentID) if agentName != agentID { - fmt.Printf(" %s %s (PID: %d) - %s\n", stateIcon, agentName, status.PID, status.State) + fmt.Fprintf(w, " %s %s (PID: %d) - %s\n", stateIcon, agentName, status.PID, status.State) } else { - fmt.Printf(" %s %s (PID: %d) - %s\n", stateIcon, agentID, status.PID, status.State) + fmt.Fprintf(w, " %s %s (PID: %d) - %s\n", stateIcon, agentID, status.PID, status.State) } } - fmt.Println() + fmt.Fprintln(w) return nil } -func sendChatMessage(runtime *cli.GuildRuntime, guildID, userID, userName, text, topic string, useWrapped bool) error { +// 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 @@ -354,7 +376,7 @@ func sendChatMessage(runtime *cli.GuildRuntime, guildID, userID, userName, text, actualTopic = topic } - fmt.Printf("šŸ“¤ Sending to topic: %s (format: %s)\n", actualTopic, msg.Format) + 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) } @@ -362,7 +384,7 @@ func sendChatMessage(runtime *cli.GuildRuntime, guildID, userID, userName, text, return nil } -func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *cli.GuildRuntime, userID string, verbose, showRouting bool) { +func displayMessages(ctx context.Context, w io.Writer, sub messageSource, runtime guildRuntime, userID string, verbose, showRouting bool) { for { select { case <-ctx.Done(): @@ -371,21 +393,21 @@ func displayMessages(ctx context.Context, sub *cli.GuildSubscription, runtime *c if !ok { return } - printMessage(msg, runtime, userID, verbose, showRouting) + printMessage(w, msg, runtime, userID, verbose, showRouting) case err, ok := <-sub.Errors(): if !ok { return } - fmt.Printf("\nāŒ Subscription error: %v\n> ", err) + fmt.Fprintf(w, "\nāŒ Subscription error: %v\n> ", err) } } } -func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID string, verbose, showRouting bool) { +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.Printf("\n[DEBUG] Format: %-50s Topics: %v\n", msg.Format, topicsDebug) + fmt.Fprintf(w, "\n[DEBUG] Format: %-50s Topics: %v\n", msg.Format, topicsDebug) } // Skip internal system messages unless verbose @@ -436,8 +458,8 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin topicStr := strings.Join(topics, ", ") // Message header - fmt.Println("\n" + strings.Repeat("─", 70)) - fmt.Printf("šŸ“Ø [%s] %s\n", timestamp, topicStr) + 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 := "" @@ -455,10 +477,10 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin // Display sender - just the name, not the ID (cleaner) if senderName != "" { - fmt.Printf(" From: %s\n", senderName) + fmt.Fprintf(w, " From: %s\n", senderName) } else if senderID != "" { // Fallback to ID if no name - fmt.Printf(" From: %s\n", senderID) + fmt.Fprintf(w, " From: %s\n", senderID) } // Message content @@ -468,7 +490,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin // Pretty print payload if verbose { prettyPayload, _ := json.MarshalIndent(payload, " ", " ") - fmt.Printf(" Payload:\n %s\n", string(prettyPayload)) + fmt.Fprintf(w, " Payload:\n %s\n", string(prettyPayload)) } else { // Show condensed version - try different message formats displayed := false @@ -479,7 +501,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin 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.Printf(" šŸ’¬ %s\n", text) + fmt.Fprintf(w, " šŸ’¬ %s\n", text) displayed = true } } @@ -493,7 +515,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin 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.Printf(" šŸ’¬ %s\n", content) + fmt.Fprintf(w, " šŸ’¬ %s\n", content) displayed = true } } @@ -504,7 +526,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin // Try simple text field if !displayed { if text, ok := payload["text"].(string); ok { - fmt.Printf(" šŸ’¬ %s\n", text) + fmt.Fprintf(w, " šŸ’¬ %s\n", text) displayed = true } } @@ -512,7 +534,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin // Try content field directly (common in some formats) if !displayed { if content, ok := payload["content"].(string); ok { - fmt.Printf(" šŸ’¬ %s\n", content) + fmt.Fprintf(w, " šŸ’¬ %s\n", content) displayed = true } } @@ -521,9 +543,9 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin if !displayed { payloadBytes, _ := json.Marshal(payload) if len(payloadBytes) > 150 { - fmt.Printf(" šŸ“„ %s...\n", string(payloadBytes[:150])) + fmt.Fprintf(w, " šŸ“„ %s...\n", string(payloadBytes[:150])) } else { - fmt.Printf(" šŸ“„ %s\n", string(payloadBytes)) + fmt.Fprintf(w, " šŸ“„ %s\n", string(payloadBytes)) } } } @@ -532,7 +554,7 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin // Routing information if showRouting && len(msg.MessageHistory) > 0 { - fmt.Println(" šŸ”€ Routing History:") + fmt.Fprintln(w, " šŸ”€ Routing History:") for i, entry := range msg.MessageHistory { agent := "" agentID := "" @@ -571,18 +593,18 @@ func printMessage(msg *protocol.Message, runtime *cli.GuildRuntime, userID strin toTopics = fmt.Sprintf(" → %s", strings.Join(entry.ToTopics, ", ")) } - fmt.Printf(" %d. %s (%s)%s%s%s\n", + fmt.Fprintf(w, " %d. %s (%s)%s%s%s\n", i+1, agent, processor, fromTopic, toTopics, reasonStr) } } if verbose && msg.RoutingSlip != nil { - fmt.Println(" šŸ“‹ Routing Slip:") + fmt.Fprintln(w, " šŸ“‹ Routing Slip:") slipBytes, _ := json.MarshalIndent(msg.RoutingSlip, " ", " ") - fmt.Printf(" %s\n", string(slipBytes)) + fmt.Fprintf(w, " %s\n", string(slipBytes)) } - fmt.Print("> ") + fmt.Fprint(w, "> ") } func findForgeRoot(startDir string) string { 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_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/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"}]}}` +) From 82eccd3f18db379f229469a8267a61dfc183d886 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Fri, 10 Jul 2026 02:06:09 -0700 Subject: [PATCH 11/16] fix(cli): unblock Windows builds and check errcheck return values The Windows CI legs (go-native-build, go-cross-build) failed to compile because cli/guild_runtime.go used the Unix-only syscall.SysProcAttr.Setpgid and syscall.Kill directly. Move the process-group handling behind setProcessGroup/killProcessGroup helpers split across process_unix.go and process_windows.go, so main.go builds on every target platform. Also fix the errcheck failures reported by go-tests: guard the deferred runtime.Shutdown() and route the miniredis seed calls through a seedStatus test helper that fails on error. --- forge-go/cli/guild_runtime.go | 15 +++++++------- forge-go/cli/guild_runtime_methods_test.go | 18 ++++++++--------- forge-go/cli/process_unix.go | 23 ++++++++++++++++++++++ forge-go/cli/process_windows.go | 20 +++++++++++++++++++ forge-go/cli/testhelpers_test.go | 8 ++++++++ forge-go/command/guild_run.go | 2 +- 6 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 forge-go/cli/process_unix.go create mode 100644 forge-go/cli/process_windows.go diff --git a/forge-go/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go index d70b93a..b8c396f 100644 --- a/forge-go/cli/guild_runtime.go +++ b/forge-go/cli/guild_runtime.go @@ -13,7 +13,6 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "time" "github.com/redis/go-redis/v9" @@ -157,7 +156,9 @@ func (r *GuildRuntime) Start() error { // Create server command cmd := exec.Command(binPath, args...) cmd.Dir = r.config.ForgeRoot - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // 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 @@ -578,12 +579,10 @@ func (r *GuildRuntime) postJSON(url string, payload, result any) error { func (r *GuildRuntime) kill() { if r.serverCmd != nil && r.serverCmd.Process != nil { - // SIGKILL the whole process group (the server is started with Setpgid). - // Both calls are best-effort during teardown: log failures rather than - // propagate, since there is nothing left to recover. - if err := syscall.Kill(-r.serverCmd.Process.Pid, syscall.SIGKILL); err != nil { - slog.Debug("failed to signal server process group", "error", err) - } + // 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) } diff --git a/forge-go/cli/guild_runtime_methods_test.go b/forge-go/cli/guild_runtime_methods_test.go index 9b5ffc7..f56a0da 100644 --- a/forge-go/cli/guild_runtime_methods_test.go +++ b/forge-go/cli/guild_runtime_methods_test.go @@ -15,11 +15,11 @@ import ( func TestGetAgentStatuses(t *testing.T) { r, mr := newMiniredisRuntime(t) guildID := "g1" - mr.Set(statusKey(guildID, "g1#manager_agent"), `{"state":"running","pid":42}`) - mr.Set(statusKey(guildID, "EchoAgent-abc"), `{"state":"starting","pid":7}`) - mr.Set(statusKey(guildID, "broken"), `not-json`) + 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. - mr.Set(statusKey("other", "x"), `{"state":"running","pid":1}`) + seedStatus(t, mr, statusKey("other", "x"), `{"state":"running","pid":1}`) statuses, err := r.GetAgentStatuses(guildID) if err != nil { @@ -40,8 +40,8 @@ func TestBuildAgentNameMap_UniqueMatchAndManager(t *testing.T) { r, mr := newMiniredisRuntime(t) guildID := "g1" spec := &protocol.GuildSpec{Name: "Echo", Agents: []protocol.AgentSpec{{Name: "EchoAgent"}}} - mr.Set(statusKey(guildID, guildID+"#manager_agent"), `{"state":"running","pid":1}`) - mr.Set(statusKey(guildID, "EchoAgent-xyz"), `{"state":"running","pid":2}`) + 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) @@ -58,7 +58,7 @@ func TestBuildAgentNameMap_AmbiguousFallsBackToID(t *testing.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"}}} - mr.Set(statusKey(guildID, "FooBar-1"), `{"state":"running","pid":1}`) + seedStatus(t, mr, statusKey(guildID, "FooBar-1"), `{"state":"running","pid":1}`) r.buildAgentNameMap(guildID, spec) @@ -81,7 +81,7 @@ func TestGetAgentName_Fallback(t *testing.T) { func TestWaitForGuildRunning(t *testing.T) { r, mr := newMiniredisRuntime(t) guildID := "g1" - mr.Set(statusKey(guildID, "a1"), `{"state":"running","pid":1}`) + 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) @@ -195,7 +195,7 @@ func TestLaunchGuild(t *testing.T) { r.redisClient = newRedisClient(t, mr) // Pre-seed the launched guild's agent as running so waitForGuildRunning // returns immediately. - mr.Set(statusKey("guild-1", "guild-1#manager_agent"), `{"state":"running","pid":1}`) + 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) 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/testhelpers_test.go b/forge-go/cli/testhelpers_test.go index f01e795..de7420c 100644 --- a/forge-go/cli/testhelpers_test.go +++ b/forge-go/cli/testhelpers_test.go @@ -65,3 +65,11 @@ func newHTTPRuntime(t *testing.T, handler http.Handler) *GuildRuntime { 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/guild_run.go b/forge-go/command/guild_run.go index d3bed29..78540cb 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -88,7 +88,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create runtime: %w", err) } - defer runtime.Shutdown() + defer func() { _ = runtime.Shutdown() }() if !guildQuiet { fmt.Println("āš™ļø Starting embedded forge server...") From 6c259fca06f67b57c417904a17abdfe2daa26f7b Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Fri, 10 Jul 2026 13:32:21 -0700 Subject: [PATCH 12/16] fix(api): render guild configuration templates on blueprint launch The blueprint launch handler merged and schema-validated the configuration bag but never substituted its values into the guild spec, so mustache {{ }} placeholders in agent names, properties, and routing steps leaked verbatim into the launched guild. The Python API server renders at this point via GuildBuilder._from_spec_dict(...).build_spec(). Add guild.RenderConfiguration, a render-only helper (no defaults/deps/ validation, no-op on empty configuration), and call it in handleLaunchGuildFromBlueprint right after the spec is decoded. Add an end-to-end test replicating the rustic-ai blueprint-vars test and expanding it to assert the rendered agent name, properties, and routing step (which the Python test never checked), plus per-launch overrides and ill-typed config rejection. A skipped test documents the shared mustache/chevron HTML-escaping bug, pending a fix in both renderers. --- forge-go/api/catalog.go | 9 + forge-go/api/catalog_templating_test.go | 274 ++++++++++++++++++++++++ forge-go/guild/builder.go | 17 ++ 3 files changed, 300 insertions(+) create mode 100644 forge-go/api/catalog_templating_test.go 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/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 From bd3b1fac1887103bf9f0c2ced295098ed08fb165 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Sat, 11 Jul 2026 00:15:24 -0700 Subject: [PATCH 13/16] fix(control): report launch PID in spawn response for short-lived agents handleSpawn only populated the spawn response PID inside a block gated on the supervisor reporting the agent as "running", then polled GetPID. A short-lived or instantly-crashing agent could exit before that check: the monitor had already cleared the live PID and flipped state to restarting, so the handler skipped the block and replied Success=true with PID=0. The race made TestHandler_SpawnWithoutGuildStore_UsesFallback (agent is /bin/echo) flaky on slower CI runners while passing locally. Track a LaunchPID on ManagedAgent that records the most recent launch PID and is not cleared on exit, exposed via ProcessSupervisor.GetLaunchPID. handleSpawn now always reports a PID for a successful launch, preferring the live PID and falling back to the launch PID. Add a unit test pinning the invariant that the launch PID survives ClearPID. --- forge-go/control/handler.go | 41 ++++++++++++++++------------- forge-go/supervisor/managed.go | 16 +++++++++++ forge-go/supervisor/managed_test.go | 34 ++++++++++++++++++++++++ forge-go/supervisor/process.go | 16 +++++++++++ 4 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 forge-go/supervisor/managed_test.go 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/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)) From f0e5303e82b9fede8c968048fce8bf7e245b01ec Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Sat, 11 Jul 2026 07:14:41 -0700 Subject: [PATCH 14/16] feat(cli): run guilds on the NATS backend to match rustic-ui The guild CLI previously launched the embedded forge server with an embedded Redis broker while its `--backend` flag (defaulting to "nats") was ignored, and always talked to Redis directly for messaging and agent status. That silently diverged from how rustic-ui launches forge, which uses the NATS data plane. Make the CLI backend-aware and default it to NATS: - Start(): pass `--backend ` through to the server. For NATS, reserve a port and boot the server's embedded NATS via `--embedded-nats-addr`, then connect the CLI's own nats.Conn. Set FORGE_EXTRA_DEPS=rusticai-nats and RUSTIC_AI_NATS_MSG_TTL (30 days) to match the desktop app. An external NATS URL is still honored. - getMessagingBackend(): build and cache a NATS or Redis messaging backend to match the transport the server was launched with. - GetAgentStatuses(): under NATS, enumerate the JetStream "agent-status" KV bucket (filtered by the sanitized guild prefix) since there is no wildcard GET; the Redis KEYS path is unchanged. - Shutdown(): drain/close the messaging backend and NATS connection. The Redis path remains fully supported via `--backend redis`. --- forge-go/cli/guild_runtime.go | 185 +++++++++++++++++++++++++++++++--- 1 file changed, 171 insertions(+), 14 deletions(-) diff --git a/forge-go/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go index b8c396f..32abb9b 100644 --- a/forge-go/cli/guild_runtime.go +++ b/forge-go/cli/guild_runtime.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -15,6 +16,7 @@ import ( "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/messaging" @@ -22,6 +24,12 @@ import ( "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" @@ -46,6 +54,9 @@ type GuildRuntime struct { 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 @@ -116,11 +127,21 @@ func (r *GuildRuntime) Start() error { return fmt.Errorf("failed to reserve listen address: %w", err) } - embeddedRedisAddr, err := reserveLocalAddr() + 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 redis address: %w", err) + return fmt.Errorf("failed to reserve broker address: %w", err) + } + if backend != "nats" { + r.redisAddr = brokerAddr } - r.redisAddr = embeddedRedisAddr // Find forge binary binPath, err := exec.LookPath("forge") @@ -140,7 +161,7 @@ func (r *GuildRuntime) Start() error { "server", "--listen", listenAddr, "--db", "sqlite:///" + r.dbPath, - "--embedded-redis-addr", embeddedRedisAddr, + "--backend", backend, "--data-dir", r.dataDir, "--dependency-config", r.config.DependencyConfig, "--with-client", @@ -149,8 +170,21 @@ func (r *GuildRuntime) Start() error { "--client-default-supervisor", r.config.SupervisorType, } - if r.config.NATSUrl != "" { - args = append(args, "--nats", r.config.NATSUrl) + 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) } // Create server command @@ -198,8 +232,16 @@ func (r *GuildRuntime) Start() error { "FORGE_QUOTA_MODE=local", "PYTHONUNBUFFERED=1", ) - if r.config.NATSUrl != "" { + 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 @@ -219,10 +261,23 @@ func (r *GuildRuntime) Start() error { return fmt.Errorf("server did not become ready: %w", err) } - // Create Redis client - r.redisClient = redis.NewClient(&redis.Options{Addr: embeddedRedisAddr}) + // 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", r.config.Backend) + slog.Info("Guild runtime started", "listen_addr", listenAddr, "backend", backend) // Seed agent registry into catalog if err := r.seedAgentRegistry(); err != nil { @@ -393,6 +448,15 @@ func (r *GuildRuntime) GetAgentName(agentID string) string { // 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) @@ -427,6 +491,77 @@ func (r *GuildRuntime) GetAgentStatuses(guildID string) (map[string]AgentStatus, 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() @@ -437,11 +572,25 @@ func (r *GuildRuntime) PublishMessage(namespace, topic string, msg *protocol.Mes return backend.PublishMessage(r.ctx, namespace, topic, msg) } -// getMessagingBackend returns the messaging backend for the runtime +// 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) { - // For now, we always use the embedded Redis backend - // The server internally handles NATS if configured - return messaging.NewRedisBackend(r.redisClient), nil + 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 @@ -450,10 +599,18 @@ func (r *GuildRuntime) Shutdown() error { 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() } From 5b4a969159829d7d919865434710ef555381d004 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Sat, 11 Jul 2026 09:00:45 -0700 Subject: [PATCH 15/16] feat(cli,server,client): add --uv-python to pin the agent interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python agents are launched with `uvx --with … python -m …`, but the command passed no interpreter constraint, so uv selected the host's newest Python. On a host whose default is 3.14 (outside forge-python's requires-python of >=3.13,<3.14) uv could not find a pyzmq wheel and fell back to a source build that fails, leaving agents in a restart loop. Let uv act as the version manager instead of inheriting the host default: - registry.ResolveCommand: insert `--python ` into the uvx command (RuntimeUVX and default branches). The value comes from FORGE_UV_PYTHON via the new registry.UVPython() helper, mirroring how FORGE_PYTHON_PKG and FORGE_EXTRA_DEPS are already read. Empty preserves prior behaviour (uv chooses). Accepts a uv Python request: "3.13", ">=3.13,<3.14", or a path. - forge server / forge client: add --uv-python, which sets FORGE_UV_PYTHON for the process that spawns agents (incl. the in-process client). - forge guild run: add --uv-python, threaded via RuntimeConfig.UVPython and passed to the embedded server subprocess. With `--uv-python 3.13` the agent env builds on 3.13 (prebuilt pyzmq wheel) regardless of the host default, removing the source-build failure. --- forge-go/cli/guild_runtime.go | 6 ++++++ forge-go/command/client.go | 8 ++++++++ forge-go/command/guild.go | 2 ++ forge-go/command/guild_run.go | 1 + forge-go/command/server.go | 8 ++++++++ forge-go/registry/registry.go | 19 ++++++++++++++++++- 6 files changed, 43 insertions(+), 1 deletion(-) diff --git a/forge-go/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go index 32abb9b..48b8f13 100644 --- a/forge-go/cli/guild_runtime.go +++ b/forge-go/cli/guild_runtime.go @@ -44,6 +44,7 @@ type RuntimeConfig struct { 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 @@ -187,6 +188,11 @@ func (r *GuildRuntime) Start() error { 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 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 index 6a86249..3c8a9bd 100644 --- a/forge-go/command/guild.go +++ b/forge-go/command/guild.go @@ -11,6 +11,7 @@ var ( guildUserName string guildSupervisor string guildPython string + guildUVPython string guildVerbose bool guildShowRouting bool guildQuiet bool @@ -80,6 +81,7 @@ func init() { 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_run.go b/forge-go/command/guild_run.go index 78540cb..94be4ad 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -70,6 +70,7 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { ForgePythonPath: filepath.Join(forgeRepoRoot, "forge-python"), SupervisorType: guildSupervisor, PythonPath: pythonPath, + UVPython: guildUVPython, } if !guildQuiet { 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/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")) +} From f7f2060c08d4d0d2b006991f0b96a63838008e2a Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Sat, 11 Jul 2026 09:17:15 -0700 Subject: [PATCH 16/16] fix(cli): wait for guild to initialize before creating the UserProxyAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REPL slept a fixed 2s after launch, then published a UserProxyAgent creation request and polled GetAgentStatuses for it to appear. But agent-process liveness ("running" in the status store) is set the moment the agent process spawns, well before the GuildManagerAgent initializes the guild (self.guild is not None). Firing the UPA request during that window makes the manager reject it with "Guild is not initialized", so the UPA is never created and user messages have nothing to unwrap and forward — the echo never comes back. Mirror rustic-ui, which defers creating the UPA / enabling messaging until the guild reports healthy: add WaitForGuildReady, which polls the server's HTTP guild endpoint until the store-level guild status reaches "running" (only set after the manager launched the guild and agents are healthy), and gate UPA creation on it instead of the fixed sleep. On timeout it warns and continues, preserving best-effort behaviour. Verified end-to-end on the echo guild: zero "Guild is not initialized" errors (was 3), the UserProxyAgent is created on the first try, and the message round-trips back through the echo agent. --- forge-go/cli/guild_runtime.go | 46 +++++++++++++++++++++++++++++++++++ forge-go/command/guild_run.go | 14 +++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/forge-go/cli/guild_runtime.go b/forge-go/cli/guild_runtime.go index 48b8f13..e513e99 100644 --- a/forge-go/cli/guild_runtime.go +++ b/forge-go/cli/guild_runtime.go @@ -19,6 +19,7 @@ import ( "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" @@ -667,6 +668,51 @@ func (r *GuildRuntime) waitForGuildRunning(guildID string, timeout time.Duration 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{ diff --git a/forge-go/command/guild_run.go b/forge-go/command/guild_run.go index 94be4ad..c6206ee 100644 --- a/forge-go/command/guild_run.go +++ b/forge-go/command/guild_run.go @@ -122,8 +122,18 @@ func runGuildREPL(cmd *cobra.Command, args []string) error { fmt.Println() } - // Wait a moment for agents to fully start - time.Sleep(2 * time.Second) + // 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)