From 0926d053b08f5504431659be5f6af44f7d565dab Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Wed, 11 Mar 2026 12:52:12 +0700 Subject: [PATCH] feat: add token usage tracking, cost analytics, budget enforcement, wake API, and activity audit trail (#142) - A1+C2: Include token usage in run.completed event payload for WS clients - A2: Cost tracking with model pricing config, cost calculation, and cost summary API - A3: Budget enforcement per agent with monthly budget limits (migration 000015) - C1: External wake/trigger API (POST /v1/agents/{id}/wake) for orchestrators - C3: Activity audit trail with structured logging and queryable API - UI: Activity page, cost stat card on overview, budget section in agent detail - i18n: Complete en/vi/zh translations for all new features --- cmd/gateway.go | 8 + cmd/gateway_http_handlers.go | 3 + cmd/gateway_managed.go | 2 + internal/agent/loop.go | 13 ++ internal/agent/loop_run.go | 12 +- internal/agent/loop_tracing.go | 16 ++ internal/agent/loop_types.go | 17 ++ internal/agent/resolver.go | 16 ++ internal/channels/quota.go | 4 +- internal/config/config.go | 21 ++- internal/gateway/server.go | 17 ++ internal/http/activity.go | 89 ++++++++++ internal/http/agents.go | 22 +++ internal/http/traces.go | 31 ++++ internal/http/wake.go | 135 +++++++++++++++ internal/store/activity_store.go | 40 +++++ internal/store/agent_store.go | 3 + internal/store/pg/activity_store.go | 107 ++++++++++++ internal/store/pg/agents.go | 10 +- internal/store/pg/factory.go | 1 + internal/store/pg/tracing.go | 76 ++++++++- internal/store/stores.go | 1 + internal/store/tracing_store.go | 22 +++ internal/tracing/cost.go | 38 +++++ internal/upgrade/version.go | 2 +- migrations/000015_agent_budget.down.sql | 2 + migrations/000015_agent_budget.up.sql | 18 ++ ui/web/src/components/layout/sidebar.tsx | 2 + ui/web/src/i18n/index.ts | 11 +- ui/web/src/i18n/locales/en/activity.json | 26 +++ ui/web/src/i18n/locales/en/agents.json | 3 + ui/web/src/i18n/locales/en/overview.json | 1 + ui/web/src/i18n/locales/en/sidebar.json | 3 +- ui/web/src/i18n/locales/vi/activity.json | 26 +++ ui/web/src/i18n/locales/vi/agents.json | 3 + ui/web/src/i18n/locales/vi/overview.json | 1 + ui/web/src/i18n/locales/vi/sidebar.json | 3 +- ui/web/src/i18n/locales/zh/activity.json | 26 +++ ui/web/src/i18n/locales/zh/agents.json | 3 + ui/web/src/i18n/locales/zh/overview.json | 1 + ui/web/src/i18n/locales/zh/sidebar.json | 3 +- ui/web/src/lib/constants.ts | 1 + ui/web/src/lib/format.ts | 6 + ui/web/src/pages/activity/activity-page.tsx | 158 ++++++++++++++++++ .../src/pages/activity/hooks/use-activity.ts | 65 +++++++ .../agents/agent-detail/agent-general-tab.tsx | 37 +++- ui/web/src/pages/overview/overview-page.tsx | 11 +- ui/web/src/pages/overview/types.ts | 1 + ui/web/src/routes.tsx | 4 + ui/web/src/types/agent.ts | 1 + 50 files changed, 1090 insertions(+), 32 deletions(-) create mode 100644 internal/http/activity.go create mode 100644 internal/http/wake.go create mode 100644 internal/store/activity_store.go create mode 100644 internal/store/pg/activity_store.go create mode 100644 internal/tracing/cost.go create mode 100644 migrations/000015_agent_budget.down.sql create mode 100644 migrations/000015_agent_budget.up.sql create mode 100644 ui/web/src/i18n/locales/en/activity.json create mode 100644 ui/web/src/i18n/locales/vi/activity.json create mode 100644 ui/web/src/i18n/locales/zh/activity.json create mode 100644 ui/web/src/pages/activity/activity-page.tsx create mode 100644 ui/web/src/pages/activity/hooks/use-activity.ts diff --git a/cmd/gateway.go b/cmd/gateway.go index 90871833..f68963e2 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -603,6 +603,9 @@ func runGateway() { if tracesH != nil { server.SetTracesHandler(tracesH) } + // External wake/trigger API + wakeH := httpapi.NewWakeHandler(agentRouter, cfg.Gateway.Token) + server.SetWakeHandler(wakeH) if mcpH != nil { server.SetMCPHandler(mcpH) } @@ -630,6 +633,11 @@ func runGateway() { server.SetPendingMessagesHandler(pendingMessagesH) } + // Activity audit log API + if pgStores.Activity != nil { + server.SetActivityHandler(httpapi.NewActivityHandler(pgStores.Activity, cfg.Gateway.Token)) + } + // Memory management API (wired directly, only needs MemoryStore + token) if pgStores != nil && pgStores.Memory != nil { server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token)) diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 2fcbb1a3..9c00bdd6 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -28,6 +28,9 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus) } agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner, isOwner) + if stores.Activity != nil { + agentsH.SetActivityStore(stores.Activity) + } } if stores != nil && stores.Skills != nil { diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 67fcce83..4d321814 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -152,6 +152,8 @@ func wireExtras( MCPPool: mcpPool, GroupWriterCache: groupWriterCache, MediaStore: mediaStore, + ModelPricing: appCfg.Telemetry.ModelPricing, + TracingStore: stores.Tracing, OnEvent: func(event agent.AgentEvent) { msgBus.Broadcast(bus.Event{ Name: protocol.EventAgent, diff --git a/internal/agent/loop.go b/internal/agent/loop.go index b4f08972..cd222e8c 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -369,6 +369,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) maxIter = req.MaxIterations } + // Budget check: query monthly spent once before starting iterations. + if l.budgetMonthlyCents > 0 && l.tracingStore != nil && l.agentUUID != uuid.Nil { + now := time.Now().UTC() + spent, err := l.tracingStore.GetMonthlyAgentCost(ctx, l.agentUUID, now.Year(), now.Month()) + if err == nil { + spentCents := int(spent * 100) + if spentCents >= l.budgetMonthlyCents { + slog.Warn("agent budget exceeded", "agent", l.id, "spent_cents", spentCents, "budget_cents", l.budgetMonthlyCents) + return nil, fmt.Errorf("monthly budget exceeded ($%.2f / $%.2f)", spent, float64(l.budgetMonthlyCents)/100) + } + } + } + for iteration < maxIter { iteration++ diff --git a/internal/agent/loop_run.go b/internal/agent/loop_run.go index 595d808d..765a4beb 100644 --- a/internal/agent/loop_run.go +++ b/internal/agent/loop_run.go @@ -157,11 +157,21 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { return nil, err } + completedPayload := map[string]any{"content": result.Content} + if result.Usage != nil { + completedPayload["usage"] = map[string]any{ + "prompt_tokens": result.Usage.PromptTokens, + "completion_tokens": result.Usage.CompletionTokens, + "total_tokens": result.Usage.TotalTokens, + "cache_creation_tokens": result.Usage.CacheCreationTokens, + "cache_read_tokens": result.Usage.CacheReadTokens, + } + } emitRun(AgentEvent{ Type: protocol.AgentEventRunCompleted, AgentID: l.id, RunID: req.RunID, - Payload: map[string]any{"content": result.Content}, + Payload: completedPayload, }) if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, 500)) diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 029acb08..3c8e3b4c 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -131,6 +131,13 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time. } } } + // Calculate cost if pricing config is available. + if pricing := tracing.LookupPricing(l.modelPricing, l.provider.Name(), l.model); pricing != nil { + cost := tracing.CalculateCost(pricing, resp.Usage) + if cost > 0 { + updates["total_cost"] = cost + } + } updates["finish_reason"] = resp.FinishReason verbose := collector.Verbose() if verbose { @@ -237,6 +244,15 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time updates["metadata"] = b } } + // Calculate cost for tool's internal LLM calls. + provider := result.Provider + model := result.Model + if pricing := tracing.LookupPricing(l.modelPricing, provider, model); pricing != nil { + cost := tracing.CalculateCost(pricing, result.Usage) + if cost > 0 { + updates["total_cost"] = cost + } + } } collector.EmitSpanUpdate(spanID, traceID, updates) diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index e46faf2a..7cd5f4a5 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -109,6 +109,13 @@ type Loop struct { // Persistent media storage for cross-turn image/document access mediaStore *media.Store + + // Model pricing config for cost tracking (nil = no cost calculation) + modelPricing map[string]*config.ModelPricing + + // Budget enforcement: monthly spending limit in cents (0 = unlimited) + budgetMonthlyCents int + tracingStore store.TracingStore } // AgentEvent is emitted during agent execution for WS broadcasting. @@ -199,6 +206,13 @@ type LoopConfig struct { // Persistent media storage for cross-turn image/document access MediaStore *media.Store + + // Model pricing for cost tracking (key = "provider/model" or "model") + ModelPricing map[string]*config.ModelPricing + + // Budget enforcement + BudgetMonthlyCents int + TracingStore store.TracingStore } func NewLoop(cfg LoopConfig) *Loop { @@ -263,6 +277,9 @@ func NewLoop(cfg LoopConfig) *Loop { groupWriterCache: cfg.GroupWriterCache, teamStore: cfg.TeamStore, mediaStore: cfg.MediaStore, + modelPricing: cfg.ModelPricing, + budgetMonthlyCents: cfg.BudgetMonthlyCents, + tracingStore: cfg.TracingStore, } } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 9115ca09..98f50124 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -76,6 +76,12 @@ type ResolverDeps struct { // Persistent media storage for cross-turn image/document access MediaStore *media.Store + + // Model pricing for cost tracking + ModelPricing map[string]*config.ModelPricing + + // Tracing store for budget enforcement queries + TracingStore store.TracingStore } // NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data. @@ -375,6 +381,9 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { GroupWriterCache: deps.GroupWriterCache, TeamStore: deps.TeamStore, MediaStore: deps.MediaStore, + ModelPricing: deps.ModelPricing, + BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents), + TracingStore: deps.TracingStore, }) slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider) @@ -400,3 +409,10 @@ func (r *Router) InvalidateAll() { slog.Debug("invalidated all agent caches") } +func derefInt(p *int) int { + if p == nil { + return 0 + } + return *p +} + diff --git a/internal/channels/quota.go b/internal/channels/quota.go index f4a53bd2..8a9bcef6 100644 --- a/internal/channels/quota.go +++ b/internal/channels/quota.go @@ -195,6 +195,7 @@ type QuotaUsageResult struct { RequestsToday int `json:"requestsToday"` InputTokensToday int64 `json:"inputTokensToday"` OutputTokensToday int64 `json:"outputTokensToday"` + CostToday float64 `json:"costToday"` UniqueUsersToday int `json:"uniqueUsersToday"` Entries []QuotaUsageEntry `json:"entries"` } @@ -276,11 +277,12 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult COUNT(*), COALESCE(SUM(total_input_tokens), 0), COALESCE(SUM(total_output_tokens), 0), + COALESCE(SUM(total_cost), 0), COUNT(DISTINCT user_id) FROM traces WHERE parent_trace_id IS NULL AND created_at >= $1`, startOfDay, - ).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.UniqueUsersToday) + ).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.CostToday, &result.UniqueUsersToday) if err != nil { slog.Warn("quota.usage: failed to query today summary", "error", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 74be61d0..0a097fc9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -296,16 +296,25 @@ func (sc *SandboxConfig) ToSandboxConfig() sandbox.Config { return cfg } +// ModelPricing defines per-million-token pricing for a model. +type ModelPricing struct { + InputPerMillion float64 `json:"input_per_million"` + OutputPerMillion float64 `json:"output_per_million"` + CacheReadPerMillion float64 `json:"cache_read_per_million,omitempty"` + CacheCreatePerMillion float64 `json:"cache_create_per_million,omitempty"` +} + // TelemetryConfig configures OpenTelemetry export for traces and spans. // When enabled, spans are exported to an OTLP-compatible backend (Jaeger, Tempo, Datadog, etc.) // in addition to PostgreSQL storage. type TelemetryConfig struct { - Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false) - Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318") - Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http" - Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev) - ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway") - Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends) + Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false) + Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318") + Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http" + Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev) + ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway") + Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends) + ModelPricing map[string]*ModelPricing `json:"model_pricing,omitempty"` // cost per model, key = "provider/model" or just "model" } // CronConfig configures the cron job system. diff --git a/internal/gateway/server.go b/internal/gateway/server.go index af43735f..37b1f6f4 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -40,6 +40,7 @@ type Server struct { agentsHandler *httpapi.AgentsHandler // agent CRUD API skillsHandler *httpapi.SkillsHandler // skill management API tracesHandler *httpapi.TracesHandler // LLM trace listing API + wakeHandler *httpapi.WakeHandler // external wake/trigger API mcpHandler *httpapi.MCPHandler // MCP server management API customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API @@ -54,6 +55,7 @@ type Server struct { storageHandler *httpapi.StorageHandler // storage file management mediaUploadHandler *httpapi.MediaUploadHandler // media upload endpoint mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint + activityHandler *httpapi.ActivityHandler // activity audit log API agentStore store.AgentStore // for context injection in tools_invoke msgBus *bus.MessageBus // for MCP bridge media delivery @@ -175,6 +177,11 @@ func (s *Server) BuildMux() *http.ServeMux { s.tracesHandler.RegisterRoutes(mux) } + // External wake/trigger API + if s.wakeHandler != nil { + s.wakeHandler.RegisterRoutes(mux) + } + // MCP server management API if s.mcpHandler != nil { s.mcpHandler.RegisterRoutes(mux) @@ -240,6 +247,10 @@ func (s *Server) BuildMux() *http.ServeMux { s.mediaServeHandler.RegisterRoutes(mux) } + if s.activityHandler != nil { + s.activityHandler.RegisterRoutes(mux) + } + // OAuth endpoints (available in all modes) if s.oauthHandler != nil { s.oauthHandler.RegisterRoutes(mux) @@ -410,6 +421,9 @@ func (s *Server) SetSkillsHandler(h *httpapi.SkillsHandler) { s.skillsHandler = // SetTracesHandler sets the LLM trace listing handler. func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler = h } +// SetWakeHandler sets the external wake/trigger handler. +func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h } + // SetMCPHandler sets the MCP server management handler. func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h } @@ -458,6 +472,9 @@ func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.memoryHandler = // SetKnowledgeGraphHandler sets the knowledge graph handler. func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) { s.kgHandler = h } +// SetActivityHandler sets the activity audit log handler. +func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) { s.activityHandler = h } + // SetAgentStore sets the agent store for context injection in tools_invoke. func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as } diff --git a/internal/http/activity.go b/internal/http/activity.go new file mode 100644 index 00000000..6bfb5334 --- /dev/null +++ b/internal/http/activity.go @@ -0,0 +1,89 @@ +package http + +import ( + "net/http" + "strconv" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ActivityHandler handles activity audit log endpoints. +type ActivityHandler struct { + activity store.ActivityStore + token string +} + +// NewActivityHandler creates a handler for activity log endpoints. +func NewActivityHandler(activity store.ActivityStore, token string) *ActivityHandler { + return &ActivityHandler{activity: activity, token: token} +} + +// RegisterRoutes registers activity routes on the given mux. +func (h *ActivityHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/activity", h.authMiddleware(h.handleList)) +} + +func (h *ActivityHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if h.token != "" { + if extractBearerToken(r) != h.token { + locale := extractLocale(r) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)}) + return + } + } + locale := extractLocale(r) + ctx := store.WithLocale(r.Context(), locale) + r = r.WithContext(ctx) + next(w, r) + } +} + +func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) { + opts := store.ActivityListOpts{ + Limit: 50, + Offset: 0, + } + + if v := r.URL.Query().Get("actor_type"); v != "" { + opts.ActorType = v + } + if v := r.URL.Query().Get("actor_id"); v != "" { + opts.ActorID = v + } + if v := r.URL.Query().Get("action"); v != "" { + opts.Action = v + } + if v := r.URL.Query().Get("entity_type"); v != "" { + opts.EntityType = v + } + if v := r.URL.Query().Get("entity_id"); v != "" { + opts.EntityID = v + } + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + opts.Limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + opts.Offset = n + } + } + + logs, err := h.activity.List(r.Context(), opts) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + total, _ := h.activity.Count(r.Context(), opts) + + writeJSON(w, http.StatusOK, map[string]any{ + "logs": logs, + "total": total, + "limit": opts.Limit, + "offset": opts.Offset, + }) +} diff --git a/internal/http/agents.go b/internal/http/agents.go index cda4991d..235b14a5 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -23,8 +23,12 @@ type AgentsHandler struct { msgBus *bus.MessageBus // for cache invalidation events (nil = no events) summoner *AgentSummoner // LLM-based agent setup (nil = disabled) isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) + activity store.ActivityStore // optional audit logging (nil = disabled) } +// SetActivityStore sets the optional activity audit store. +func (h *AgentsHandler) SetActivityStore(a store.ActivityStore) { h.activity = a } + // NewAgentsHandler creates a handler for agent management endpoints. // isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled). func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler { @@ -47,6 +51,21 @@ func (h *AgentsHandler) emitCacheInvalidate(kind, key string) { }) } +// logActivity records an audit log entry if the activity store is set. +func (h *AgentsHandler) logActivity(r *http.Request, actorID, action, entityType, entityID string) { + if h.activity == nil { + return + } + h.activity.Log(r.Context(), &store.ActivityLog{ + ActorType: "user", + ActorID: actorID, + Action: action, + EntityType: entityType, + EntityID: entityID, + IPAddress: r.RemoteAddr, + }) +} + // RegisterRoutes registers all agent management routes on the given mux. func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/agents", h.authMiddleware(h.handleList)) @@ -184,6 +203,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description) } + h.logActivity(r, userID, "agent.created", "agent", req.ID.String()) writeJSON(w, http.StatusCreated, req) } @@ -279,6 +299,7 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { } } + h.logActivity(r, userID, "agent.updated", "agent", id.String()) writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } @@ -311,5 +332,6 @@ func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey) h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String()) + h.logActivity(r, userID, "agent.deleted", "agent", id.String()) writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } diff --git a/internal/http/traces.go b/internal/http/traces.go index d9fdb74e..ba6e74fe 100644 --- a/internal/http/traces.go +++ b/internal/http/traces.go @@ -3,6 +3,7 @@ package http import ( "net/http" "strconv" + "time" "github.com/google/uuid" @@ -25,6 +26,7 @@ func NewTracesHandler(tracing store.TracingStore, token string) *TracesHandler { func (h *TracesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/traces", h.authMiddleware(h.handleList)) mux.HandleFunc("GET /v1/traces/{traceID}", h.authMiddleware(h.handleGet)) + mux.HandleFunc("GET /v1/costs/summary", h.authMiddleware(h.handleCostSummary)) } func (h *TracesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { @@ -117,3 +119,32 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) { "spans": spans, }) } + +func (h *TracesHandler) handleCostSummary(w http.ResponseWriter, r *http.Request) { + opts := store.CostSummaryOpts{} + + if v := r.URL.Query().Get("agent_id"); v != "" { + id, err := uuid.Parse(v) + if err == nil { + opts.AgentID = &id + } + } + if v := r.URL.Query().Get("from"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + opts.From = &t + } + } + if v := r.URL.Query().Get("to"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + opts.To = &t + } + } + + rows, err := h.tracing.GetCostSummary(r.Context(), opts) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"rows": rows}) +} diff --git a/internal/http/wake.go b/internal/http/wake.go new file mode 100644 index 00000000..905a99a2 --- /dev/null +++ b/internal/http/wake.go @@ -0,0 +1,135 @@ +package http + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/sessions" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// WakeHandler handles POST /v1/agents/{id}/wake — external trigger API. +// Allows orchestrators (Paperclip, n8n, etc.) to trigger agent runs via HTTP. +type WakeHandler struct { + agents *agent.Router + token string +} + +// NewWakeHandler creates a handler for the wake endpoint. +func NewWakeHandler(agents *agent.Router, token string) *WakeHandler { + return &WakeHandler{agents: agents, token: token} +} + +// RegisterRoutes registers wake routes on the given mux. +func (h *WakeHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/agents/{id}/wake", h.handleWake) +} + +type wakeRequest struct { + Message string `json:"message"` + SessionKey string `json:"session_key,omitempty"` + UserID string `json:"user_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type wakeResponse struct { + Content string `json:"content"` + RunID string `json:"run_id"` + Usage *wakeUsage `json:"usage,omitempty"` +} + +type wakeUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + + // Auth check + if h.token != "" && !tokenMatch(extractBearerToken(r), h.token) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)}) + return + } + + agentID := r.PathValue("id") + if agentID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) + return + } + + // Limit request body size + const maxBodySize = 1 << 20 // 1MB + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + + var req wakeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())}) + return + } + + if req.Message == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"}) + return + } + + loop, err := h.agents.Get(agentID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID)}) + return + } + + // Build session key + sessionKey := req.SessionKey + if sessionKey == "" { + sessionKey = sessions.SessionKey(agentID, "wake-"+uuid.NewString()[:8]) + } + + userID := req.UserID + if userID == "" { + userID = extractUserID(r) + } + + ctx := store.WithLocale(r.Context(), locale) + if userID != "" { + ctx = store.WithUserID(ctx, userID) + } + + runID := uuid.NewString() + slog.Info("wake request", "agent", agentID, "user", userID, "session", sessionKey) + + result, err := loop.Run(ctx, agent.RunRequest{ + SessionKey: sessionKey, + Message: req.Message, + Channel: "wake", + ChatID: "api", + RunID: runID, + UserID: userID, + Stream: false, + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("agent run failed: %v", err)}) + return + } + + resp := wakeResponse{ + Content: result.Content, + RunID: runID, + } + if result.Usage != nil { + resp.Usage = &wakeUsage{ + PromptTokens: result.Usage.PromptTokens, + CompletionTokens: result.Usage.CompletionTokens, + TotalTokens: result.Usage.TotalTokens, + } + } + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/store/activity_store.go b/internal/store/activity_store.go new file mode 100644 index 00000000..c8ebcf0f --- /dev/null +++ b/internal/store/activity_store.go @@ -0,0 +1,40 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// ActivityLog represents a single audit log entry. +type ActivityLog struct { + ID uuid.UUID `json:"id"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + Action string `json:"action"` + EntityType string `json:"entity_type,omitempty"` + EntityID string `json:"entity_id,omitempty"` + Details json.RawMessage `json:"details,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ActivityListOpts configures activity log listing. +type ActivityListOpts struct { + ActorType string + ActorID string + Action string + EntityType string + EntityID string + Limit int + Offset int +} + +// ActivityStore manages activity audit logs. +type ActivityStore interface { + Log(ctx context.Context, entry *ActivityLog) error + List(ctx context.Context, opts ActivityListOpts) ([]ActivityLog, error) + Count(ctx context.Context, opts ActivityListOpts) (int, error) +} diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 8a53c0c2..d7a6473a 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -39,6 +39,9 @@ type AgentData struct { IsDefault bool `json:"is_default"` Status string `json:"status"` + // Budget: optional monthly spending limit in cents (nil = unlimited) + BudgetMonthlyCents *int `json:"budget_monthly_cents,omitempty"` + // Per-agent JSONB config (nullable — nil means "use global defaults") ToolsConfig json.RawMessage `json:"tools_config,omitempty"` SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` diff --git a/internal/store/pg/activity_store.go b/internal/store/pg/activity_store.go new file mode 100644 index 00000000..ee400920 --- /dev/null +++ b/internal/store/pg/activity_store.go @@ -0,0 +1,107 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGActivityStore implements store.ActivityStore backed by Postgres. +type PGActivityStore struct { + db *sql.DB +} + +// NewPGActivityStore creates a new PGActivityStore. +func NewPGActivityStore(db *sql.DB) *PGActivityStore { + return &PGActivityStore{db: db} +} + +func (s *PGActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + entry.ActorType, entry.ActorID, entry.Action, + entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, + ) + return err +} + +func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) { + where, args := buildActivityWhere(opts) + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + args = append(args, limit, opts.Offset) + + query := fmt.Sprintf( + `SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), details, COALESCE(ip_address,''), created_at + FROM activity_logs %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, + where, len(args)-1, len(args), + ) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.ActivityLog + for rows.Next() { + var a store.ActivityLog + if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.EntityType, &a.EntityID, &a.Details, &a.IPAddress, &a.CreatedAt); err != nil { + return nil, err + } + result = append(result, a) + } + return result, rows.Err() +} + +func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) { + where, args := buildActivityWhere(opts) + query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where) + + var count int + err := s.db.QueryRowContext(ctx, query, args...).Scan(&count) + return count, err +} + +func buildActivityWhere(opts store.ActivityListOpts) (string, []any) { + var conditions []string + var args []any + idx := 1 + + if opts.ActorType != "" { + conditions = append(conditions, fmt.Sprintf("actor_type = $%d", idx)) + args = append(args, opts.ActorType) + idx++ + } + if opts.ActorID != "" { + conditions = append(conditions, fmt.Sprintf("actor_id = $%d", idx)) + args = append(args, opts.ActorID) + idx++ + } + if opts.Action != "" { + conditions = append(conditions, fmt.Sprintf("action = $%d", idx)) + args = append(args, opts.Action) + idx++ + } + if opts.EntityType != "" { + conditions = append(conditions, fmt.Sprintf("entity_type = $%d", idx)) + args = append(args, opts.EntityType) + idx++ + } + if opts.EntityID != "" { + conditions = append(conditions, fmt.Sprintf("entity_id = $%d", idx)) + args = append(args, opts.EntityID) + idx++ + } + + if len(conditions) == 0 { + return "", nil + } + return "WHERE " + strings.Join(conditions, " AND "), args +} diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 72a05a4e..8fd5a0ce 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -104,7 +104,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, - agent_type, is_default, status, created_at, updated_at` + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at` func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error { if agent.ID == uuid.Nil { @@ -118,13 +118,13 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, - agent_type, is_default, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`, + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model, agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace, jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig), jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig), - agent.AgentType, agent.IsDefault, agent.Status, now, now, + agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, ) if err != nil { return err @@ -330,7 +330,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) { err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model, &d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace, &toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg, - &d.AgentType, &d.IsDefault, &d.Status, &d.CreatedAt, &d.UpdatedAt) + &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt) if err != nil { return nil, err } diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index 112de531..2f839cfa 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -42,5 +42,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { PendingMessages: NewPGPendingMessageStore(db), KnowledgeGraph: NewPGKnowledgeGraphStore(db), Contacts: NewPGContactStore(db), + Activity: NewPGActivityStore(db), }, nil } diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index 461b9487..ec42941f 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -29,14 +29,14 @@ func (s *PGTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData _, err := s.db.ExecContext(ctx, `INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, - total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count, + total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count, status, error, metadata, tags, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey), nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime), nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel), nilStr(trace.InputPreview), nilStr(trace.OutputPreview), - trace.TotalInputTokens, trace.TotalOutputTokens, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount, + trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount, trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), trace.CreatedAt, ) return err @@ -58,12 +58,12 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor err := s.db.QueryRowContext(ctx, `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, - total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count, + total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, status, error, metadata, tags, created_at FROM traces WHERE id = $1`, traceID, ).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, &durationMS, &name, &channel, &inputPreview, &outputPreview, - &d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, + &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, &d.Status, &errStr, &metadata, &tags, &d.CreatedAt) if err != nil { return nil, err @@ -135,7 +135,7 @@ func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpt q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, - total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count, + total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, status, error, metadata, tags, created_at FROM traces` + where @@ -163,7 +163,7 @@ func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpt if err := rows.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, &durationMS, &name, &channel, &inputPreview, &outputPreview, - &d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, + &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, &d.Status, &errStr, &metadata, &tags, &d.CreatedAt); err != nil { continue } @@ -346,6 +346,7 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID tool_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = $1 AND span_type = 'tool_call'), total_input_tokens = COALESCE((SELECT SUM(input_tokens) FROM spans WHERE trace_id = $1 AND span_type = 'llm_call' AND input_tokens IS NOT NULL), 0), total_output_tokens = COALESCE((SELECT SUM(output_tokens) FROM spans WHERE trace_id = $1 AND span_type = 'llm_call' AND output_tokens IS NOT NULL), 0), + total_cost = COALESCE((SELECT SUM(total_cost) FROM spans WHERE trace_id = $1 AND total_cost IS NOT NULL), 0), metadata = ( SELECT jsonb_build_object( 'total_cache_read_tokens', COALESCE(SUM((metadata->>'cache_read_tokens')::int), 0), @@ -356,3 +357,64 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID WHERE id = $1`, traceID) return err } + +func (s *PGTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) { + start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + var cost float64 + err := s.db.QueryRowContext(ctx, + `SELECT COALESCE(SUM(total_cost), 0) FROM traces + WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL`, + agentID, start, end, + ).Scan(&cost) + return cost, err +} + +func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSummaryOpts) ([]store.CostSummaryRow, error) { + var conditions []string + var args []any + argIdx := 1 + + // Only root traces (not delegations) + conditions = append(conditions, "parent_trace_id IS NULL") + + if opts.AgentID != nil { + conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) + args = append(args, *opts.AgentID) + argIdx++ + } + if opts.From != nil { + conditions = append(conditions, fmt.Sprintf("created_at >= $%d", argIdx)) + args = append(args, *opts.From) + argIdx++ + } + if opts.To != nil { + conditions = append(conditions, fmt.Sprintf("created_at < $%d", argIdx)) + args = append(args, *opts.To) + argIdx++ + } + + where := " WHERE " + strings.Join(conditions, " AND ") + + q := `SELECT agent_id, COALESCE(SUM(total_cost), 0), COALESCE(SUM(total_input_tokens), 0), + COALESCE(SUM(total_output_tokens), 0), COUNT(*) + FROM traces` + where + ` GROUP BY agent_id ORDER BY SUM(total_cost) DESC` + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.CostSummaryRow + for rows.Next() { + var r store.CostSummaryRow + var agentID *uuid.UUID + if err := rows.Scan(&agentID, &r.TotalCost, &r.TotalInputTokens, &r.TotalOutputTokens, &r.TraceCount); err != nil { + continue + } + r.AgentID = agentID + result = append(result, r) + } + return result, nil +} diff --git a/internal/store/stores.go b/internal/store/stores.go index e6a6e080..b0447958 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -23,4 +23,5 @@ type Stores struct { PendingMessages PendingMessageStore KnowledgeGraph KnowledgeGraphStore Contacts ContactStore + Activity ActivityStore } diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 0747dac3..bfd9bd77 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -54,6 +54,7 @@ type TraceData struct { OutputPreview string `json:"output_preview,omitempty"` TotalInputTokens int `json:"total_input_tokens"` TotalOutputTokens int `json:"total_output_tokens"` + TotalCost float64 `json:"total_cost"` SpanCount int `json:"span_count"` LLMCallCount int `json:"llm_call_count"` ToolCallCount int `json:"tool_call_count"` @@ -82,6 +83,7 @@ type SpanData struct { Provider string `json:"provider,omitempty"` InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` + TotalCost *float64 `json:"total_cost,omitempty"` FinishReason string `json:"finish_reason,omitempty"` ModelParams json.RawMessage `json:"model_params,omitempty"` ToolName string `json:"tool_name,omitempty"` @@ -102,6 +104,22 @@ type TraceListOpts struct { Offset int } +// CostSummaryOpts configures cost aggregation queries. +type CostSummaryOpts struct { + AgentID *uuid.UUID + From *time.Time + To *time.Time +} + +// CostSummaryRow is a single row of aggregated cost data. +type CostSummaryRow struct { + AgentID *uuid.UUID `json:"agent_id,omitempty"` + TotalCost float64 `json:"total_cost"` + TotalInputTokens int `json:"total_input_tokens"` + TotalOutputTokens int `json:"total_output_tokens"` + TraceCount int `json:"trace_count"` +} + // TracingStore manages LLM traces and spans. type TracingStore interface { CreateTrace(ctx context.Context, trace *TraceData) error @@ -117,4 +135,8 @@ type TracingStore interface { // Batch operations (async flush) BatchCreateSpans(ctx context.Context, spans []SpanData) error BatchUpdateTraceAggregates(ctx context.Context, traceID uuid.UUID) error + + // Cost aggregation + GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) + GetCostSummary(ctx context.Context, opts CostSummaryOpts) ([]CostSummaryRow, error) } diff --git a/internal/tracing/cost.go b/internal/tracing/cost.go new file mode 100644 index 00000000..aaa18a6d --- /dev/null +++ b/internal/tracing/cost.go @@ -0,0 +1,38 @@ +package tracing + +import ( + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +// CalculateCost computes the USD cost for a single LLM call based on token usage and pricing. +// Returns 0 if pricing is nil. +func CalculateCost(pricing *config.ModelPricing, usage *providers.Usage) float64 { + if pricing == nil || usage == nil { + return 0 + } + cost := float64(usage.PromptTokens) * pricing.InputPerMillion / 1_000_000 + cost += float64(usage.CompletionTokens) * pricing.OutputPerMillion / 1_000_000 + if pricing.CacheReadPerMillion > 0 && usage.CacheReadTokens > 0 { + cost += float64(usage.CacheReadTokens) * pricing.CacheReadPerMillion / 1_000_000 + } + if pricing.CacheCreatePerMillion > 0 && usage.CacheCreationTokens > 0 { + cost += float64(usage.CacheCreationTokens) * pricing.CacheCreatePerMillion / 1_000_000 + } + return cost +} + +// LookupPricing finds the model pricing from config. +// Tries "provider/model" first, then just "model". +func LookupPricing(pricingMap map[string]*config.ModelPricing, provider, model string) *config.ModelPricing { + if pricingMap == nil { + return nil + } + if p, ok := pricingMap[provider+"/"+model]; ok { + return p + } + if p, ok := pricingMap[model]; ok { + return p + } + return nil +} diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 554af766..27a650a0 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 14 +const RequiredSchemaVersion uint = 15 diff --git a/migrations/000015_agent_budget.down.sql b/migrations/000015_agent_budget.down.sql new file mode 100644 index 00000000..a793b5aa --- /dev/null +++ b/migrations/000015_agent_budget.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS activity_logs; +ALTER TABLE agents DROP COLUMN IF EXISTS budget_monthly_cents; diff --git a/migrations/000015_agent_budget.up.sql b/migrations/000015_agent_budget.up.sql new file mode 100644 index 00000000..c6ba0d7b --- /dev/null +++ b/migrations/000015_agent_budget.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE agents ADD COLUMN budget_monthly_cents INTEGER; + +CREATE TABLE activity_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + actor_type VARCHAR(20) NOT NULL, + actor_id VARCHAR(255) NOT NULL, + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50), + entity_id VARCHAR(255), + details JSONB, + ip_address VARCHAR(45), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_activity_logs_actor ON activity_logs (actor_type, actor_id); +CREATE INDEX idx_activity_logs_action ON activity_logs (action); +CREATE INDEX idx_activity_logs_entity ON activity_logs (entity_type, entity_id); +CREATE INDEX idx_activity_logs_created ON activity_logs (created_at DESC); diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index d148c616..97da2f05 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -19,6 +19,7 @@ import { Volume2, Cpu, ArrowRightLeft, + ClipboardList, HardDrive, Inbox, Brain, @@ -106,6 +107,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { + diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index 11960312..09c342d0 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -31,6 +31,7 @@ import enMemory from "./locales/en/memory.json"; import enStorage from "./locales/en/storage.json"; import enPendingMessages from "./locales/en/pending-messages.json"; import enContacts from "./locales/en/contacts.json"; +import enActivity from "./locales/en/activity.json"; // --- VI namespaces --- import viCommon from "./locales/vi/common.json"; @@ -62,6 +63,7 @@ import viMemory from "./locales/vi/memory.json"; import viStorage from "./locales/vi/storage.json"; import viPendingMessages from "./locales/vi/pending-messages.json"; import viContacts from "./locales/vi/contacts.json"; +import viActivity from "./locales/vi/activity.json"; // --- ZH namespaces --- import zhCommon from "./locales/zh/common.json"; @@ -93,6 +95,7 @@ import zhMemory from "./locales/zh/memory.json"; import zhStorage from "./locales/zh/storage.json"; import zhPendingMessages from "./locales/zh/pending-messages.json"; import zhContacts from "./locales/zh/contacts.json"; +import zhActivity from "./locales/zh/activity.json"; const STORAGE_KEY = "goclaw:language"; @@ -110,7 +113,7 @@ const ns = [ "agents", "teams", "sessions", "skills", "cron", "config", "channels", "providers", "traces", "events", "delegations", "usage", "approvals", "nodes", "logs", "tools", "mcp", "tts", - "setup", "memory", "storage", "pending-messages", "contacts", + "setup", "memory", "storage", "pending-messages", "contacts", "activity", ] as const; i18n.use(initReactI18next).init({ @@ -124,7 +127,7 @@ i18n.use(initReactI18next).init({ approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools, mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage, "pending-messages": enPendingMessages, - contacts: enContacts, + contacts: enContacts, activity: enActivity, }, vi: { common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin, @@ -135,7 +138,7 @@ i18n.use(initReactI18next).init({ approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools, mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage, "pending-messages": viPendingMessages, - contacts: viContacts, + contacts: viContacts, activity: viActivity, }, zh: { common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin, @@ -146,7 +149,7 @@ i18n.use(initReactI18next).init({ approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools, mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage, "pending-messages": zhPendingMessages, - contacts: zhContacts, + contacts: zhContacts, activity: zhActivity, }, }, ns: [...ns], diff --git a/ui/web/src/i18n/locales/en/activity.json b/ui/web/src/i18n/locales/en/activity.json new file mode 100644 index 00000000..c7f3f89c --- /dev/null +++ b/ui/web/src/i18n/locales/en/activity.json @@ -0,0 +1,26 @@ +{ + "title": "Activity Log", + "description": "Audit trail of admin and configuration changes.", + "filters": { + "action": "Action", + "allActions": "All Actions", + "agentCreated": "Agent Created", + "agentUpdated": "Agent Updated", + "agentDeleted": "Agent Deleted", + "entityType": "Entity Type", + "allEntities": "All Entities", + "agent": "Agent" + }, + "table": { + "action": "Action", + "actor": "Actor", + "entity": "Entity", + "entityId": "Entity ID", + "ip": "IP Address", + "time": "Time" + }, + "empty": { + "title": "No activity logs", + "description": "Activity logs will appear here when admin actions are performed." + } +} diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 5a8f0c69..b1df3683 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -81,6 +81,9 @@ "selfEvolutionLabel": "Allow agent to evolve its communication style", "selfEvolutionHint": "When enabled, the agent can update its SOUL.md to refine tone, vocabulary, and response style based on interactions. Identity, name, and operating instructions remain locked.", "selfEvolutionInfo": "Agent will evolve its style over time through SOUL.md updates. Only style and tone are affected — identity and workflow rules stay fixed.", + "budget": "Monthly Budget", + "budgetLabel": "Budget limit (USD)", + "budgetHint": "Optional monthly spending cap. Agent will reject requests when exceeded. Leave empty for unlimited.", "saveChanges": "Save Changes", "saving": "Saving...", "saved": "Saved", diff --git a/ui/web/src/i18n/locales/en/overview.json b/ui/web/src/i18n/locales/en/overview.json index 4501b027..804d0106 100644 --- a/ui/web/src/i18n/locales/en/overview.json +++ b/ui/web/src/i18n/locales/en/overview.json @@ -4,6 +4,7 @@ "statCards": { "requestsToday": "Requests Today", "tokensToday": "Tokens Today", + "costToday": "Cost Today", "agents": "Agents", "channels": "Channels", "running": "running", diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index 28ab7e2f..4d4da4f4 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -34,6 +34,7 @@ "config": "Config", "approvals": "Approvals", "nodes": "Nodes", - "tts": "TTS" + "tts": "TTS", + "activity": "Activity" } } diff --git a/ui/web/src/i18n/locales/vi/activity.json b/ui/web/src/i18n/locales/vi/activity.json new file mode 100644 index 00000000..b87a5b1a --- /dev/null +++ b/ui/web/src/i18n/locales/vi/activity.json @@ -0,0 +1,26 @@ +{ + "title": "Nhật ký hoạt động", + "description": "Lịch sử kiểm toán các thay đổi quản trị và cấu hình.", + "filters": { + "action": "Hành động", + "allActions": "Tất cả", + "agentCreated": "Tạo Agent", + "agentUpdated": "Cập nhật Agent", + "agentDeleted": "Xóa Agent", + "entityType": "Loại đối tượng", + "allEntities": "Tất cả", + "agent": "Agent" + }, + "table": { + "action": "Hành động", + "actor": "Người thực hiện", + "entity": "Đối tượng", + "entityId": "ID đối tượng", + "ip": "Địa chỉ IP", + "time": "Thời gian" + }, + "empty": { + "title": "Chưa có nhật ký", + "description": "Nhật ký hoạt động sẽ xuất hiện khi có thao tác quản trị." + } +} diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 247da7b3..beefe232 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -81,6 +81,9 @@ "selfEvolutionLabel": "Cho phép agent tiến hóa phong cách giao tiếp", "selfEvolutionHint": "Khi được bật, agent có thể cập nhật SOUL.md để tinh chỉnh giọng điệu, từ vựng và phong cách phản hồi dựa trên tương tác. Nhận dạng, tên và hướng dẫn hoạt động vẫn bị khóa.", "selfEvolutionInfo": "Agent sẽ tiến hóa phong cách theo thời gian qua các cập nhật SOUL.md. Chỉ phong cách và giọng điệu bị ảnh hưởng — nhận dạng và quy tắc quy trình làm việc vẫn cố định.", + "budget": "Ngân sách hàng tháng", + "budgetLabel": "Giới hạn ngân sách (USD)", + "budgetHint": "Giới hạn chi tiêu hàng tháng tùy chọn. Agent sẽ từ chối yêu cầu khi vượt quá. Để trống cho không giới hạn.", "saveChanges": "Lưu thay đổi", "saving": "Đang lưu...", "saved": "Đã lưu", diff --git a/ui/web/src/i18n/locales/vi/overview.json b/ui/web/src/i18n/locales/vi/overview.json index 3dcf13e6..d99cb59c 100644 --- a/ui/web/src/i18n/locales/vi/overview.json +++ b/ui/web/src/i18n/locales/vi/overview.json @@ -56,6 +56,7 @@ "requestsToday": "Request hôm nay", "running": "Đang chạy", "tokensToday": "Token hôm nay", + "costToday": "Chi phí hôm nay", "users": "Người dùng" }, "systemHealth": { diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index be16da26..67640d1f 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -34,6 +34,7 @@ "config": "Cấu hình", "approvals": "Phê duyệt", "nodes": "Nodes", - "tts": "TTS" + "tts": "TTS", + "activity": "Hoạt động" } } diff --git a/ui/web/src/i18n/locales/zh/activity.json b/ui/web/src/i18n/locales/zh/activity.json new file mode 100644 index 00000000..76d96a30 --- /dev/null +++ b/ui/web/src/i18n/locales/zh/activity.json @@ -0,0 +1,26 @@ +{ + "title": "活动日志", + "description": "管理和配置变更的审计记录。", + "filters": { + "action": "操作", + "allActions": "全部", + "agentCreated": "创建 Agent", + "agentUpdated": "更新 Agent", + "agentDeleted": "删除 Agent", + "entityType": "实体类型", + "allEntities": "全部", + "agent": "Agent" + }, + "table": { + "action": "操作", + "actor": "操作者", + "entity": "实体", + "entityId": "实体 ID", + "ip": "IP 地址", + "time": "时间" + }, + "empty": { + "title": "暂无活动日志", + "description": "执行管理操作后,活动日志将在此显示。" + } +} diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 03d7dee5..0fa10804 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -81,6 +81,9 @@ "selfEvolutionLabel": "允许Agent进化其沟通风格", "selfEvolutionHint": "启用后,Agent可以更新其 SOUL.md 以根据交互改善语调、词汇和响应风格。身份、名称和操作指令保持锁定。", "selfEvolutionInfo": "Agent将通过 SOUL.md 更新随时间进化其风格。只有风格和语调受影响 — 身份和工作流规则保持固定。", + "budget": "月度预算", + "budgetLabel": "预算限额 (USD)", + "budgetHint": "可选的月度支出上限。超出后 Agent 将拒绝请求。留空表示无限制。", "saveChanges": "保存更改", "saving": "保存中...", "saved": "已保存", diff --git a/ui/web/src/i18n/locales/zh/overview.json b/ui/web/src/i18n/locales/zh/overview.json index 153dc8ba..294dda24 100644 --- a/ui/web/src/i18n/locales/zh/overview.json +++ b/ui/web/src/i18n/locales/zh/overview.json @@ -56,6 +56,7 @@ "requestsToday": "今日请求", "running": "运行中", "tokensToday": "今日 Token", + "costToday": "今日费用", "users": "用户" }, "systemHealth": { diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index f90f2ae0..f9224930 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -34,6 +34,7 @@ "storage": "存储", "traces": "追踪", "tts": "TTS", - "usage": "用量" + "usage": "用量", + "activity": "活动日志" } } diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 796f4790..51966c32 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -34,6 +34,7 @@ export const ROUTES = { PENDING_MESSAGES: "/pending-messages", MEMORY: "/memory", KNOWLEDGE_GRAPH: "/knowledge-graph", + ACTIVITY: "/activity", SETUP: "/setup", } as const; diff --git a/ui/web/src/lib/format.ts b/ui/web/src/lib/format.ts index 2cf7c873..009e2984 100644 --- a/ui/web/src/lib/format.ts +++ b/ui/web/src/lib/format.ts @@ -31,6 +31,12 @@ export function formatTokens(count: number | null | undefined): string { return count.toString(); } +export function formatCost(cost: number | null | undefined): string { + if (cost == null || cost === 0) return "$0.00"; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(2)}`; +} + export function formatDuration(ms: number | undefined | null): string { if (ms == null || isNaN(ms)) return "—"; if (ms < 1000) return `${ms}ms`; diff --git a/ui/web/src/pages/activity/activity-page.tsx b/ui/web/src/pages/activity/activity-page.tsx new file mode 100644 index 00000000..b2a44013 --- /dev/null +++ b/ui/web/src/pages/activity/activity-page.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect } from "react"; +import { ClipboardList, RefreshCw } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { PageHeader } from "@/components/shared/page-header"; +import { EmptyState } from "@/components/shared/empty-state"; +import { Pagination } from "@/components/shared/pagination"; +import { TableSkeleton } from "@/components/shared/loading-skeleton"; +import { formatDate } from "@/lib/format"; +import { useActivity } from "./hooks/use-activity"; +import { useMinLoading } from "@/hooks/use-min-loading"; +import { useDeferredLoading } from "@/hooks/use-deferred-loading"; + +const ACTION_COLORS: Record = { + "agent.created": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "agent.updated": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + "agent.deleted": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", +}; + +export function ActivityPage() { + const { t } = useTranslation("activity"); + const { logs, total, loading, load } = useActivity(); + const spinning = useMinLoading(loading); + const showSkeleton = useDeferredLoading(loading && logs.length === 0); + const [actionFilter, setActionFilter] = useState("all"); + const [entityFilter, setEntityFilter] = useState("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + useEffect(() => { + load({ + action: actionFilter !== "all" ? actionFilter : undefined, + entity_type: entityFilter !== "all" ? entityFilter : undefined, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + }, [page, pageSize, actionFilter, entityFilter]); + + const handleRefresh = () => { + load({ + action: actionFilter !== "all" ? actionFilter : undefined, + entity_type: entityFilter !== "all" ? entityFilter : undefined, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + }; + + return ( +
+ + {t("common:refresh", "Refresh")} + + } + /> + + {/* Filters */} +
+ + + +
+ + {showSkeleton ? ( + + ) : logs.length === 0 ? ( + + ) : ( + <> +
+ + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + ))} + +
{t("table.action")}{t("table.actor")}{t("table.entity")}{t("table.entityId")}{t("table.ip")}{t("table.time")}
+ + {log.action} + + + {log.actor_type}:{log.actor_id} + {log.entity_type || "—"} + {log.entity_id || "—"} + + {log.ip_address || "—"} + + {formatDate(log.created_at)} +
+
+ +
+ { setPageSize(s); setPage(1); }} + /> +
+ + )} +
+ ); +} diff --git a/ui/web/src/pages/activity/hooks/use-activity.ts b/ui/web/src/pages/activity/hooks/use-activity.ts new file mode 100644 index 00000000..7e296679 --- /dev/null +++ b/ui/web/src/pages/activity/hooks/use-activity.ts @@ -0,0 +1,65 @@ +import { useState, useEffect, useCallback } from "react"; +import { useHttp } from "@/hooks/use-ws"; + +export interface ActivityLog { + id: string; + actor_type: string; + actor_id: string; + action: string; + entity_type: string; + entity_id: string; + details: unknown; + ip_address: string; + created_at: string; +} + +interface ActivityListResponse { + logs: ActivityLog[]; + total: number; + limit: number; + offset: number; +} + +interface ActivityFilters { + action?: string; + entity_type?: string; + actor_id?: string; + limit?: number; + offset?: number; +} + +export function useActivity() { + const http = useHttp(); + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + + const load = useCallback( + async (filters?: ActivityFilters) => { + setLoading(true); + try { + const params: Record = {}; + if (filters?.action) params.action = filters.action; + if (filters?.entity_type) params.entity_type = filters.entity_type; + if (filters?.actor_id) params.actor_id = filters.actor_id; + if (filters?.limit) params.limit = String(filters.limit); + if (filters?.offset) params.offset = String(filters.offset); + + const res = await http.get("/v1/activity", params); + setLogs(res.logs ?? []); + setTotal(res.total ?? 0); + } catch { + // ignore + } finally { + setLoading(false); + } + }, + [http], + ); + + useEffect(() => { + load(); + }, [load]); + + return { logs, total, loading, load }; +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx index f0157b1c..94f0278e 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx @@ -1,7 +1,8 @@ import { useState, useCallback } from "react"; -import { Save, Check, AlertCircle, Sparkles, Info } from "lucide-react"; +import { Save, Check, AlertCircle, Sparkles, Info, DollarSign } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; @@ -32,6 +33,11 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { // Workspace const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace); + // Budget (stored in cents, displayed in dollars) + const [budgetDollars, setBudgetDollars] = useState( + agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "", + ); + // Self-evolve (predefined agents only) const otherCfg = (agent.other_config ?? {}) as Record; const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve)); @@ -51,6 +57,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { setSaved(false); try { const updatedOtherConfig = { ...otherCfg, self_evolve: selfEvolve }; + const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null; await onUpdate({ display_name: displayName, frontmatter: frontmatter || null, @@ -62,6 +69,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { status, is_default: isDefault, other_config: updatedOtherConfig, + budget_monthly_cents: budgetCents, }); setSaved(true); setTimeout(() => setSaved(false), 3000); @@ -110,6 +118,33 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { onRestrictChange={setRestrictToWorkspace} /> + {/* Budget */} + +
+
+ +

{t("general.budget")}

+
+
+ +

+ {t("general.budgetHint")} +

+
+ setBudgetDollars(e.target.value)} + className="max-w-[200px]" + /> +
+ {/* Self-Evolve (predefined agents only) */} {agent.agent_type === "predefined" && ( <> diff --git a/ui/web/src/pages/overview/overview-page.tsx b/ui/web/src/pages/overview/overview-page.tsx index 753dc591..9feb313c 100644 --- a/ui/web/src/pages/overview/overview-page.tsx +++ b/ui/web/src/pages/overview/overview-page.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback } from "react"; -import { Activity, Bot, Hash, Radio, AlertTriangle } from "lucide-react"; +import { Activity, Bot, DollarSign, Hash, Radio, AlertTriangle } from "lucide-react"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@/components/shared/page-header"; @@ -12,7 +12,7 @@ import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useTraces } from "@/pages/traces/hooks/use-traces"; import { Methods, Events } from "@/api/protocol"; import { ROUTES } from "@/lib/constants"; -import { formatTokens } from "@/lib/format"; +import { formatTokens, formatCost } from "@/lib/format"; import type { HealthPayload, @@ -132,7 +132,7 @@ export function OverviewPage() { )} {/* Summary cards */} -
+
+ const ContactsPage = lazy(() => import("@/pages/contacts/contacts-page").then((m) => ({ default: m.ContactsPage })), ); +const ActivityPage = lazy(() => + import("@/pages/activity/activity-page").then((m) => ({ default: m.ActivityPage })), +); function PageLoader() { return ( @@ -145,6 +148,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts index 098a315c..6c2ca070 100644 --- a/ui/web/src/types/agent.ts +++ b/ui/web/src/types/agent.ts @@ -102,6 +102,7 @@ export interface AgentData { compaction_config?: CompactionConfig | null; context_pruning?: ContextPruningConfig | null; other_config?: Record | null; + budget_monthly_cents?: number | null; } export interface AgentShareData {