package controllers import ( "context" "errors" "strconv" "strings" "sync" "time" "unicode/utf8" "doormile/db" "doormile/internal/ai/playground" "doormile/utils" "github.com/gofiber/fiber/v2" ) // POST /admin/ai/playground/run — Agent Studio's Test tab. Runs one prompt // through the configured model with a registry skill's tools; see internal/ai/playground for // what executes and what only becomes a proposal. Staff only, roleid 1 only, // and rate-limited per user because every run is a paid API call. // PlaygroundModel is the model client the playground uses (an OpenAI-compatible // provider such as Groq, see main.go). Nil until PLAYGROUND_LLM_API_KEY is set; the endpoint then answers 503 and the console keeps the // Test tab labelled as unavailable. var PlaygroundModel playground.Model const ( playgroundRunTimeout = 120 * time.Second playgroundRunsPerWin = 10 playgroundWindow = 10 * time.Minute ) type playgroundLimiter struct { mu sync.Mutex runs map[string][]time.Time } var playgroundRuns = &playgroundLimiter{runs: map[string][]time.Time{}} // allow records a run for key and reports whether it is within the limit. func (l *playgroundLimiter) allow(key string, now time.Time) bool { l.mu.Lock() defer l.mu.Unlock() kept := l.runs[key][:0] for _, t := range l.runs[key] { if now.Sub(t) < playgroundWindow { kept = append(kept, t) } } if len(kept) >= playgroundRunsPerWin { l.runs[key] = kept return false } l.runs[key] = append(kept, now) return true } // RunAIPlayground — POST /admin/ai/playground/run {agentid, skillid?, prompt} func RunAIPlayground(c *fiber.Ctx) error { if PlaygroundModel == nil { return utils.Fail(c, fiber.StatusServiceUnavailable, "PLAYGROUND_NOT_CONFIGURED", "The Test playground has no model configured on this server.") } var body struct { Agentid string `json:"agentid"` Skillid string `json:"skillid"` Prompt string `json:"prompt"` } if err := c.BodyParser(&body); err != nil { return utils.BadRequest(c, "invalid request body") } body.Prompt = strings.TrimSpace(body.Prompt) if body.Agentid == "" || body.Prompt == "" { return utils.BadRequest(c, "agentid and prompt are required") } if utf8.RuneCountInString(body.Prompt) > playground.MaxPromptChars { return utils.BadRequest(c, "prompt is too long (at most 2000 characters)") } actor := actorOf(c) key := actor.Email if key == "" { key = "user:" + strconv.Itoa(actor.UserID) } if !playgroundRuns.allow(key, time.Now()) { return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_RATE_LIMITED", "Playground limit reached: 10 runs per 10 minutes. Try again shortly.") } snap, ok := loadRegistry(c) if !ok { return nil } plan, err := playground.Prepare(snap, body.Agentid, body.Skillid) if errors.Is(err, playground.ErrNotFound) { return utils.NotFound(c, err.Error()) } if err != nil { return utils.BadRequest(c, err.Error()) } // An OpenAI-compatible provider serves its own configured model, not the // agent's registry pin (a Claude id AI_engine uses); report the real one. if named, ok := PlaygroundModel.(interface{ ModelName() string }); ok { plan.Model = named.ModelName() } ctx, cancel := context.WithTimeout(context.Background(), playgroundRunTimeout) defer cancel() trace, err := playground.Run(ctx, PlaygroundModel, plan, body.Prompt, playground.Executors(db.DB, db.Rdb)) utils.Info("ai playground run", "email", actor.Email, "agent", plan.AgentID, "skill", plan.SkillID, "model", plan.Model, "turns", trace.Turns, "ms", trace.Ms, "failed", err != nil) if err != nil { utils.Error("ai playground: model call failed", "error", err.Error()) var pe *playground.ProviderError if errors.As(err, &pe) && pe.Status == fiber.StatusTooManyRequests { return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_PROVIDER_RATE_LIMITED", "The model provider's rate limit was reached (common on free plans). Wait a minute and try again.") } return utils.Fail(c, fiber.StatusBadGateway, "PLAYGROUND_MODEL_FAILED", "The model request failed; nothing was changed. Try again.") } return utils.OK(c, trace) }