mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 10:13:37 +00:00
* feat(hooks/observe): add structured output validation hook (ObserveHook) - hooks/types.go: Add ObserveHook interface + BuiltInHookType enum - hooks/dispatcher.go: HookDispatcher emits ObserveHook with PhaseResult payload - pipeline/observe_stage.go: ObserveStage emits hook after ObserveResult built - pipeline/substates.go: ObserveStageResult carries hook results + validation errors - hooks/config.go: BuiltInHookTypeObserve added to BuiltInHookType enum PhaseResult carries structured output, token usage, tool calls + validation errors emitted post-ObserveStage. ObserveHook implementations can validate structured output against schemas, detect tool-call loops, enforce token budgets, etc. Hook fires after ObserveStage produces ObserveResult, before results propagate to next stage. ValidationError returned by hook halts pipeline and propagates error to caller without further stage execution. Co-Authored-By: Claude <noreply@anthropic.com> * ui(hooks): add post_model_response event to web UI - Add event to Zod schema, filter dropdown, and form dialog - Implement conditional test panel UI for model response payload - Add translations (en/zh/vi) for new test panel fields - Updated beta description to reference the new event Co-Authored-By: Claude <noreply@anthropic.com> * feat(mcp): add MCP CRUD server exposing goclaw resources at /api/mcp/ with Bearer token auth and X-GoClaw-Tenant-Id header, default to master tenant * feat(mcp): add goclaw_skills_write_file tool to edit skill files on disk The CRUD MCP server's goclaw_skills_update only touched skill DB metadata, with no way to edit a skill's SKILL.md/file content on the filesystem. Extract the versioned write logic from the web UI's skill file editor (SkillsHandler.handleWriteFile) into skills.WriteVersionedFile so both surfaces share identical validation and versioning, and expose it as a new MCP tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
365 lines
12 KiB
Go
365 lines
12 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// SQLiteAgentStore implements store.AgentStore backed by SQLite.
|
|
type SQLiteAgentStore struct {
|
|
db *sql.DB
|
|
// embProvider is intentionally omitted — vector search not supported in SQLite lite edition.
|
|
}
|
|
|
|
func NewSQLiteAgentStore(db *sql.DB) *SQLiteAgentStore {
|
|
return &SQLiteAgentStore{db: db}
|
|
}
|
|
|
|
// SetEmbeddingProvider is a no-op for SQLite — vector not supported.
|
|
func (s *SQLiteAgentStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {}
|
|
|
|
// agentSelectCols is the column list for all agent SELECT queries.
|
|
const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, provider, model,
|
|
context_window, max_tool_iterations, workspace, restrict_to_workspace,
|
|
tools_config, sandbox_config, subagents_config, memory_config,
|
|
compaction_config, context_pruning, other_config,
|
|
emoji, agent_description, thinking_level, max_tokens,
|
|
self_evolve, skill_evolve, skill_nudge_interval,
|
|
reasoning_config, workspace_sharing, chatgpt_oauth_routing,
|
|
model_fallback, shell_deny_groups, kg_dedup_config,
|
|
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id`
|
|
|
|
func (s *SQLiteAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
|
|
if agent.ID == uuid.Nil {
|
|
agent.ID = store.GenNewID()
|
|
}
|
|
now := time.Now()
|
|
agent.CreatedAt = now
|
|
agent.UpdatedAt = now
|
|
tenantID := agent.TenantID
|
|
if tenantID == uuid.Nil {
|
|
tenantID = store.MasterTenantID
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model,
|
|
context_window, max_tool_iterations, workspace, restrict_to_workspace,
|
|
tools_config, sandbox_config, subagents_config, memory_config,
|
|
compaction_config, context_pruning, other_config,
|
|
emoji, agent_description, thinking_level, max_tokens,
|
|
self_evolve, skill_evolve, skill_nudge_interval,
|
|
reasoning_config, workspace_sharing, chatgpt_oauth_routing,
|
|
model_fallback, shell_deny_groups, kg_dedup_config,
|
|
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
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.Emoji, agent.AgentDescription, agent.ThinkingLevel, agent.MaxTokens,
|
|
agent.SelfEvolve, agent.SkillEvolve, agent.SkillNudgeInterval,
|
|
jsonOrEmpty(agent.ReasoningConfig), jsonOrEmpty(agent.WorkspaceSharing), jsonOrEmpty(agent.ChatGPTOAuthRouting),
|
|
jsonOrEmpty(agent.ModelFallback), jsonOrEmpty(agent.ShellDenyGroups), jsonOrEmpty(agent.KGDedupConfig),
|
|
agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents,
|
|
now, now, tenantID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) {
|
|
var row *sql.Row
|
|
if store.IsCrossTenant(ctx) {
|
|
row = s.db.QueryRowContext(ctx,
|
|
`SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL`, agentKey)
|
|
} else {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return nil, fmt.Errorf("agent not found: %s", agentKey)
|
|
}
|
|
row = s.db.QueryRowContext(ctx,
|
|
`SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL AND tenant_id = ?`,
|
|
agentKey, tid)
|
|
}
|
|
d, err := scanAgentRow(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agent not found: %s", agentKey)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (s *SQLiteAgentStore) GetByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
|
|
var row *sql.Row
|
|
if store.IsCrossTenant(ctx) {
|
|
row = s.db.QueryRowContext(ctx,
|
|
`SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id)
|
|
} else {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return nil, fmt.Errorf("agent not found: %s", id)
|
|
}
|
|
row = s.db.QueryRowContext(ctx,
|
|
`SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL AND tenant_id = ?`, id, tid)
|
|
}
|
|
d, err := scanAgentRow(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agent not found: %s", id)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (s *SQLiteAgentStore) GetByIDUnscoped(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id)
|
|
d, err := scanAgentRow(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agent not found: %s", id)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (s *SQLiteAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
|
|
if len(updates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Coerce NOT NULL columns: null → default to prevent constraint violations.
|
|
// Promoted TEXT columns: null → empty string.
|
|
for _, col := range []string{"emoji", "agent_description", "thinking_level"} {
|
|
if v, ok := updates[col]; ok && v == nil {
|
|
updates[col] = ""
|
|
}
|
|
}
|
|
// Promoted INT/BOOL columns: null → 0/false.
|
|
for _, col := range []string{"skill_nudge_interval", "max_tokens", "self_evolve", "skill_evolve", "is_default"} {
|
|
if v, ok := updates[col]; ok && v == nil {
|
|
if col == "self_evolve" || col == "skill_evolve" || col == "is_default" {
|
|
updates[col] = false
|
|
} else {
|
|
updates[col] = 0
|
|
}
|
|
}
|
|
}
|
|
// NOT NULL JSON columns: null → empty object.
|
|
for _, col := range []string{"other_config", "tools_config", "reasoning_config", "workspace_sharing", "chatgpt_oauth_routing", "model_fallback", "shell_deny_groups", "kg_dedup_config"} {
|
|
if v, ok := updates[col]; ok && isEmptyOrNullJSONUpdate(v) {
|
|
updates[col] = []byte("{}")
|
|
}
|
|
}
|
|
|
|
// Unset existing default before setting a new one (scoped to same tenant).
|
|
if v, ok := updates["is_default"]; ok {
|
|
if isDefault, _ := v.(bool); isDefault {
|
|
if store.IsCrossTenant(ctx) {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
"UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL", id); err != nil {
|
|
slog.Warn("agents.unset_default", "error", err)
|
|
}
|
|
} else {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
"UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL AND tenant_id = ?",
|
|
id, tid); err != nil {
|
|
slog.Warn("agents.unset_default", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updates["updated_at"] = time.Now()
|
|
if store.IsCrossTenant(ctx) {
|
|
return execMapUpdate(ctx, s.db, "agents", id, updates)
|
|
}
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return fmt.Errorf("agent not found: %s", id)
|
|
}
|
|
return execMapUpdateWhereTenant(ctx, s.db, "agents", updates, id, tid)
|
|
}
|
|
|
|
func isEmptyOrNullJSONUpdate(v any) bool {
|
|
if v == nil {
|
|
return true
|
|
}
|
|
switch data := v.(type) {
|
|
case json.RawMessage:
|
|
return len(data) == 0 || strings.TrimSpace(string(data)) == "null"
|
|
case []byte:
|
|
return len(data) == 0 || strings.TrimSpace(string(data)) == "null"
|
|
case string:
|
|
return strings.TrimSpace(data) == "" || strings.TrimSpace(data) == "null"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *SQLiteAgentStore) Delete(ctx context.Context, id uuid.UUID) error {
|
|
if store.IsCrossTenant(ctx) {
|
|
_, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ?", id)
|
|
return err
|
|
}
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return fmt.Errorf("agent not found: %s", id)
|
|
}
|
|
_, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ? AND tenant_id = ?", id, tid)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentData, error) {
|
|
q := `SELECT ` + agentSelectCols + ` FROM agents WHERE deleted_at IS NULL`
|
|
var args []any
|
|
|
|
if ownerID != "" {
|
|
q += " AND owner_id = ?"
|
|
args = append(args, ownerID)
|
|
}
|
|
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
slog.Warn("agents.List: tenant context missing, returning empty (fail-closed)")
|
|
return nil, nil
|
|
}
|
|
q += " AND tenant_id = ?"
|
|
args = append(args, tid)
|
|
}
|
|
|
|
q += " ORDER BY created_at DESC"
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanAgentRows(rows)
|
|
}
|
|
|
|
func (s *SQLiteAgentStore) GetDefault(ctx context.Context) (*store.AgentData, error) {
|
|
if store.IsCrossTenant(ctx) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+agentSelectCols+`
|
|
FROM agents WHERE deleted_at IS NULL
|
|
ORDER BY is_default DESC, created_at ASC LIMIT 1`)
|
|
return scanAgentRow(row)
|
|
}
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
return nil, fmt.Errorf("agent not found: default")
|
|
}
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+agentSelectCols+`
|
|
FROM agents WHERE deleted_at IS NULL AND tenant_id = ?
|
|
ORDER BY is_default DESC, created_at ASC LIMIT 1`, tid)
|
|
return scanAgentRow(row)
|
|
}
|
|
|
|
// --- Scan helpers ---
|
|
|
|
type agentRowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
|
|
var d store.AgentData
|
|
var frontmatter sql.NullString
|
|
var toolsCfg, sandboxCfg, subagentsCfg, memoryCfg, compactionCfg, pruningCfg, otherCfg *[]byte
|
|
var reasoningCfg, wsCfg, oauthCfg, fallbackCfg, shellCfg, kgCfg *[]byte
|
|
createdAt, updatedAt := scanTimePair()
|
|
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.Emoji, &d.AgentDescription, &d.ThinkingLevel, &d.MaxTokens,
|
|
&d.SelfEvolve, &d.SkillEvolve, &d.SkillNudgeInterval,
|
|
&reasoningCfg, &wsCfg, &oauthCfg, &fallbackCfg, &shellCfg, &kgCfg,
|
|
&d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents,
|
|
createdAt, updatedAt, &d.TenantID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.CreatedAt = createdAt.Time
|
|
d.UpdatedAt = updatedAt.Time
|
|
if frontmatter.Valid {
|
|
d.Frontmatter = frontmatter.String
|
|
}
|
|
if toolsCfg != nil {
|
|
d.ToolsConfig = *toolsCfg
|
|
}
|
|
if sandboxCfg != nil {
|
|
d.SandboxConfig = *sandboxCfg
|
|
}
|
|
if subagentsCfg != nil {
|
|
d.SubagentsConfig = *subagentsCfg
|
|
}
|
|
if memoryCfg != nil {
|
|
d.MemoryConfig = *memoryCfg
|
|
}
|
|
if compactionCfg != nil {
|
|
d.CompactionConfig = *compactionCfg
|
|
}
|
|
if pruningCfg != nil {
|
|
d.ContextPruning = *pruningCfg
|
|
}
|
|
if otherCfg != nil {
|
|
d.OtherConfig = *otherCfg
|
|
}
|
|
if reasoningCfg != nil {
|
|
d.ReasoningConfig = *reasoningCfg
|
|
}
|
|
if wsCfg != nil {
|
|
d.WorkspaceSharing = *wsCfg
|
|
}
|
|
if oauthCfg != nil {
|
|
d.ChatGPTOAuthRouting = *oauthCfg
|
|
}
|
|
if fallbackCfg != nil {
|
|
d.ModelFallback = *fallbackCfg
|
|
}
|
|
if shellCfg != nil {
|
|
d.ShellDenyGroups = *shellCfg
|
|
}
|
|
if kgCfg != nil {
|
|
d.KGDedupConfig = *kgCfg
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
func scanAgentRows(rows *sql.Rows) ([]store.AgentData, error) {
|
|
var result []store.AgentData
|
|
for rows.Next() {
|
|
d, err := scanAgentRow(rows)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result = append(result, *d)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// ResetStuckSummoning flips rows with status='summoning' to 'summon_failed'.
|
|
// Called at startup to recover from crashes where summon goroutines died mid-flight.
|
|
func (s *SQLiteAgentStore) ResetStuckSummoning(ctx context.Context) (int64, error) {
|
|
const q = `UPDATE agents SET status = ? WHERE status = ?`
|
|
res, err := s.db.ExecContext(ctx, q, store.AgentStatusSummonFailed, store.AgentStatusSummoning)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n, nil
|
|
}
|