diff --git a/db/migrations/0001_init.up.sql b/db/migrations/0001_init.up.sql index 49d1f011..0ca9b491 100644 --- a/db/migrations/0001_init.up.sql +++ b/db/migrations/0001_init.up.sql @@ -134,6 +134,7 @@ CREATE INDEX IF NOT EXISTS idx_lifecycle_events_event_type ON lifecycle_events(e CREATE TABLE IF NOT EXISTS history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), messages JSONB NOT NULL, + skills TEXT[] NOT NULL DEFAULT '{}'::text[], timestamp TIMESTAMPTZ NOT NULL, "user" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE ); diff --git a/db/queries/history.sql b/db/queries/history.sql index bbb00ec8..c2392ced 100644 --- a/db/queries/history.sql +++ b/db/queries/history.sql @@ -1,21 +1,21 @@ -- name: CreateHistory :one -INSERT INTO history (messages, timestamp, "user") -VALUES ($1, $2, $3) -RETURNING id, messages, timestamp, "user"; +INSERT INTO history (messages, skills, timestamp, "user") +VALUES ($1, $2, $3, $4) +RETURNING id, messages, skills, timestamp, "user"; -- name: ListHistoryByUserSince :many -SELECT id, messages, timestamp, "user" +SELECT id, messages, skills, timestamp, "user" FROM history WHERE "user" = $1 AND timestamp >= $2 ORDER BY timestamp ASC; -- name: GetHistoryByID :one -SELECT id, messages, timestamp, "user" +SELECT id, messages, skills, timestamp, "user" FROM history WHERE id = $1; -- name: ListHistoryByUser :many -SELECT id, messages, timestamp, "user" +SELECT id, messages, skills, timestamp, "user" FROM history WHERE "user" = $1 ORDER BY timestamp DESC diff --git a/docs/docs.go b/docs/docs.go index 8a5cf17a..669d08f2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2366,6 +2366,20 @@ const docTemplate = `{ } }, "definitions": { + "chat.AgentSkill": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "chat.ChatRequest": { "type": "object", "properties": { @@ -2404,6 +2418,18 @@ const docTemplate = `{ }, "query": { "type": "string" + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.AgentSkill" + } + }, + "use_skills": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -2421,6 +2447,12 @@ const docTemplate = `{ }, "provider": { "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -2615,6 +2647,12 @@ const docTemplate = `{ "type": "object", "additionalProperties": true } + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -2642,6 +2680,12 @@ const docTemplate = `{ "additionalProperties": true } }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, "timestamp": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 92a3c711..23a67206 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2357,6 +2357,20 @@ } }, "definitions": { + "chat.AgentSkill": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "chat.ChatRequest": { "type": "object", "properties": { @@ -2395,6 +2409,18 @@ }, "query": { "type": "string" + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.AgentSkill" + } + }, + "use_skills": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -2412,6 +2438,12 @@ }, "provider": { "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -2606,6 +2638,12 @@ "type": "object", "additionalProperties": true } + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -2633,6 +2671,12 @@ "additionalProperties": true } }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, "timestamp": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 04608597..98f37e5e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,13 @@ definitions: + chat.AgentSkill: + properties: + content: + type: string + description: + type: string + name: + type: string + type: object chat.ChatRequest: properties: current_platform: @@ -25,6 +34,14 @@ definitions: type: string query: type: string + skills: + items: + $ref: '#/definitions/chat.AgentSkill' + type: array + use_skills: + items: + type: string + type: array type: object chat.ChatResponse: properties: @@ -36,6 +53,10 @@ definitions: type: string provider: type: string + skills: + items: + type: string + type: array type: object chat.GatewayMessage: additionalProperties: true @@ -162,6 +183,10 @@ definitions: additionalProperties: true type: object type: array + skills: + items: + type: string + type: array type: object history.ListResponse: properties: @@ -179,6 +204,10 @@ definitions: additionalProperties: true type: object type: array + skills: + items: + type: string + type: array timestamp: type: string user_id: diff --git a/internal/chat/resolver.go b/internal/chat/resolver.go index 0bd90077..4ce2626b 100644 --- a/internal/chat/resolver.go +++ b/internal/chat/resolver.go @@ -85,16 +85,22 @@ func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, err } var messages []GatewayMessage + var historySkills []string if !skipHistory { messages, err = r.loadHistoryMessages(ctx, req.UserID, maxContextLoadTime) if err != nil { return ChatResponse{}, err } + historySkills, err = r.loadHistorySkills(ctx, req.UserID, maxContextLoadTime) + if err != nil { + return ChatResponse{}, err + } } if len(req.Messages) > 0 { messages = append(messages, req.Messages...) } messages = sanitizeGatewayMessages(messages) + useSkills := normalizeSkills(append(historySkills, req.UseSkills...)) payload := agentGatewayRequest{ APIKey: provider.ApiKey, @@ -109,6 +115,8 @@ func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, err CurrentPlatform: req.CurrentPlatform, Messages: messages, Query: req.Query, + Skills: req.Skills, + UseSkills: useSkills, } payload.Language = language @@ -117,7 +125,7 @@ func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, err return ChatResponse{}, err } - if err := r.storeHistory(ctx, req.UserID, req.Query, resp.Messages); err != nil { + if err := r.storeHistory(ctx, req.UserID, req.Query, resp.Messages, resp.Skills); err != nil { return ChatResponse{}, err } if err := r.storeMemory(ctx, req.UserID, req.Query, resp.Messages); err != nil { @@ -126,6 +134,7 @@ func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, err return ChatResponse{ Messages: resp.Messages, + Skills: resp.Skills, Model: chatModel.ModelID, Provider: provider.ClientType, }, nil @@ -163,6 +172,11 @@ func (r *Resolver) TriggerSchedule(ctx context.Context, userID string, schedule if err != nil { return err } + historySkills, err := r.loadHistorySkills(ctx, userID, maxContextLoadTime) + if err != nil { + return err + } + useSkills := normalizeSkills(historySkills) payload := agentGatewayScheduleRequest{ APIKey: provider.ApiKey, @@ -178,13 +192,14 @@ func (r *Resolver) TriggerSchedule(ctx context.Context, userID string, schedule Messages: messages, Query: schedule.Command, Schedule: schedule, + UseSkills: useSkills, } resp, err := r.postSchedule(ctx, payload, token) if err != nil { return err } - if err := r.storeHistory(ctx, userID, schedule.Command, resp.Messages); err != nil { + if err := r.storeHistory(ctx, userID, schedule.Command, resp.Messages, resp.Skills); err != nil { return err } if err := r.storeMemory(ctx, userID, schedule.Command, resp.Messages); err != nil { @@ -235,17 +250,24 @@ func (r *Resolver) StreamChat(ctx context.Context, req ChatRequest) (<-chan Stre } var messages []GatewayMessage + var historySkills []string if !skipHistory { messages, err = r.loadHistoryMessages(ctx, req.UserID, maxContextLoadTime) if err != nil { errChan <- err return } + historySkills, err = r.loadHistorySkills(ctx, req.UserID, maxContextLoadTime) + if err != nil { + errChan <- err + return + } } if len(req.Messages) > 0 { messages = append(messages, req.Messages...) } messages = sanitizeGatewayMessages(messages) + useSkills := normalizeSkills(append(historySkills, req.UseSkills...)) payload := agentGatewayRequest{ APIKey: provider.ApiKey, @@ -260,6 +282,8 @@ func (r *Resolver) StreamChat(ctx context.Context, req ChatRequest) (<-chan Stre CurrentPlatform: req.CurrentPlatform, Messages: messages, Query: req.Query, + Skills: req.Skills, + UseSkills: useSkills, } payload.Language = language @@ -285,6 +309,8 @@ type agentGatewayRequest struct { CurrentPlatform string `json:"currentPlatform,omitempty"` Messages []GatewayMessage `json:"messages"` Query string `json:"query"` + Skills []AgentSkill `json:"skills,omitempty"` + UseSkills []string `json:"useSkills,omitempty"` } type agentGatewayScheduleRequest struct { @@ -301,10 +327,13 @@ type agentGatewayScheduleRequest struct { Messages []GatewayMessage `json:"messages"` Query string `json:"query"` Schedule SchedulePayload `json:"schedule"` + Skills []AgentSkill `json:"skills,omitempty"` + UseSkills []string `json:"useSkills,omitempty"` } type agentGatewayResponse struct { Messages []GatewayMessage `json:"messages"` + Skills []string `json:"skills"` } func (r *Resolver) postChat(ctx context.Context, payload agentGatewayRequest, token string) (agentGatewayResponse, error) { @@ -472,7 +501,36 @@ func (r *Resolver) loadHistoryMessages(ctx context.Context, userID string, maxCo return messages, nil } -func (r *Resolver) storeHistory(ctx context.Context, userID, query string, responseMessages []GatewayMessage) error { +func (r *Resolver) loadHistorySkills(ctx context.Context, userID string, maxContextLoadTime int) ([]string, error) { + if r.queries == nil { + return nil, fmt.Errorf("history queries not configured") + } + pgUserID, err := parseUUID(userID) + if err != nil { + return nil, err + } + from := time.Now().UTC().Add(-time.Duration(normalizeMaxContextLoad(maxContextLoadTime)) * time.Minute) + rows, err := r.queries.ListHistoryByUserSince(ctx, sqlc.ListHistoryByUserSinceParams{ + User: pgUserID, + Timestamp: pgtype.Timestamptz{ + Time: from, + Valid: true, + }, + }) + if err != nil { + return nil, err + } + combined := make([]string, 0, len(rows)) + for _, row := range rows { + if len(row.Skills) == 0 { + continue + } + combined = append(combined, row.Skills...) + } + return normalizeSkills(combined), nil +} + +func (r *Resolver) storeHistory(ctx context.Context, userID, query string, responseMessages []GatewayMessage, skills []string) error { if r.queries == nil { return fmt.Errorf("history queries not configured") } @@ -493,8 +551,10 @@ func (r *Resolver) storeHistory(ctx context.Context, userID, query string, respo if err := r.ensureUserExists(ctx, pgUserID); err != nil { return err } + normalizedSkills := normalizeSkills(skills) _, err = r.queries.CreateHistory(ctx, sqlc.CreateHistoryParams{ Messages: payload, + Skills: normalizedSkills, Timestamp: pgtype.Timestamptz{ Time: time.Now().UTC(), Valid: true, @@ -579,7 +639,7 @@ func (r *Resolver) tryStoreFromStreamPayload(ctx context.Context, userID, query, // Case 1: event: done + data: {messages: [...]} if eventType == "done" { if parsed, ok := parseGatewayResponse([]byte(data)); ok { - return r.storeRound(ctx, userID, query, parsed.Messages) + return r.storeRound(ctx, userID, query, parsed.Messages, parsed.Skills) } } @@ -591,14 +651,14 @@ func (r *Resolver) tryStoreFromStreamPayload(ctx context.Context, userID, query, if err := json.Unmarshal([]byte(data), &envelope); err == nil { if envelope.Type == "done" && len(envelope.Data) > 0 { if parsed, ok := parseGatewayResponse(envelope.Data); ok { - return r.storeRound(ctx, userID, query, parsed.Messages) + return r.storeRound(ctx, userID, query, parsed.Messages, parsed.Skills) } } } // Case 3: data: {messages:[...]} without event if parsed, ok := parseGatewayResponse([]byte(data)); ok { - return r.storeRound(ctx, userID, query, parsed.Messages) + return r.storeRound(ctx, userID, query, parsed.Messages, parsed.Skills) } return false, nil } @@ -614,8 +674,8 @@ func parseGatewayResponse(payload []byte) (agentGatewayResponse, bool) { return parsed, true } -func (r *Resolver) storeRound(ctx context.Context, userID, query string, messages []GatewayMessage) (bool, error) { - if err := r.storeHistory(ctx, userID, query, messages); err != nil { +func (r *Resolver) storeRound(ctx context.Context, userID, query string, messages []GatewayMessage, skills []string) (bool, error) { + if err := r.storeHistory(ctx, userID, query, messages, skills); err != nil { return true, err } if err := r.storeMemory(ctx, userID, query, messages); err != nil { @@ -624,6 +684,23 @@ func (r *Resolver) storeRound(ctx context.Context, userID, query string, message return true, nil } +func normalizeSkills(skills []string) []string { + seen := map[string]struct{}{} + normalized := make([]string, 0, len(skills)) + for _, skill := range skills { + trimmed := strings.TrimSpace(skill) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + normalized = append(normalized, trimmed) + } + return normalized +} + func gatewayMessageToMemory(msg GatewayMessage) (string, string) { role := "assistant" if raw, ok := msg["role"].(string); ok && strings.TrimSpace(raw) != "" { diff --git a/internal/chat/types.go b/internal/chat/types.go index 49f62dbc..117ad40e 100644 --- a/internal/chat/types.go +++ b/internal/chat/types.go @@ -9,6 +9,12 @@ type Message struct { type GatewayMessage map[string]interface{} +type AgentSkill struct { + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` +} + type ChatRequest struct { UserID string `json:"-"` Token string `json:"-"` @@ -22,10 +28,13 @@ type ChatRequest struct { Platforms []string `json:"platforms,omitempty"` CurrentPlatform string `json:"current_platform,omitempty"` Messages []GatewayMessage `json:"messages,omitempty"` + Skills []AgentSkill `json:"skills,omitempty"` + UseSkills []string `json:"use_skills,omitempty"` } type ChatResponse struct { Messages []GatewayMessage `json:"messages"` + Skills []string `json:"skills,omitempty"` Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` } diff --git a/internal/db/sqlc/history.sql.go b/internal/db/sqlc/history.sql.go index 00f6f50b..83de32ba 100644 --- a/internal/db/sqlc/history.sql.go +++ b/internal/db/sqlc/history.sql.go @@ -12,23 +12,30 @@ import ( ) const createHistory = `-- name: CreateHistory :one -INSERT INTO history (messages, timestamp, "user") -VALUES ($1, $2, $3) -RETURNING id, messages, timestamp, "user" +INSERT INTO history (messages, skills, timestamp, "user") +VALUES ($1, $2, $3, $4) +RETURNING id, messages, skills, timestamp, "user" ` type CreateHistoryParams struct { Messages []byte `json:"messages"` + Skills []string `json:"skills"` Timestamp pgtype.Timestamptz `json:"timestamp"` User pgtype.UUID `json:"user"` } func (q *Queries) CreateHistory(ctx context.Context, arg CreateHistoryParams) (History, error) { - row := q.db.QueryRow(ctx, createHistory, arg.Messages, arg.Timestamp, arg.User) + row := q.db.QueryRow(ctx, createHistory, + arg.Messages, + arg.Skills, + arg.Timestamp, + arg.User, + ) var i History err := row.Scan( &i.ID, &i.Messages, + &i.Skills, &i.Timestamp, &i.User, ) @@ -56,7 +63,7 @@ func (q *Queries) DeleteHistoryByUser(ctx context.Context, user pgtype.UUID) err } const getHistoryByID = `-- name: GetHistoryByID :one -SELECT id, messages, timestamp, "user" +SELECT id, messages, skills, timestamp, "user" FROM history WHERE id = $1 ` @@ -67,6 +74,7 @@ func (q *Queries) GetHistoryByID(ctx context.Context, id pgtype.UUID) (History, err := row.Scan( &i.ID, &i.Messages, + &i.Skills, &i.Timestamp, &i.User, ) @@ -74,7 +82,7 @@ func (q *Queries) GetHistoryByID(ctx context.Context, id pgtype.UUID) (History, } const listHistoryByUser = `-- name: ListHistoryByUser :many -SELECT id, messages, timestamp, "user" +SELECT id, messages, skills, timestamp, "user" FROM history WHERE "user" = $1 ORDER BY timestamp DESC @@ -98,6 +106,7 @@ func (q *Queries) ListHistoryByUser(ctx context.Context, arg ListHistoryByUserPa if err := rows.Scan( &i.ID, &i.Messages, + &i.Skills, &i.Timestamp, &i.User, ); err != nil { @@ -112,7 +121,7 @@ func (q *Queries) ListHistoryByUser(ctx context.Context, arg ListHistoryByUserPa } const listHistoryByUserSince = `-- name: ListHistoryByUserSince :many -SELECT id, messages, timestamp, "user" +SELECT id, messages, skills, timestamp, "user" FROM history WHERE "user" = $1 AND timestamp >= $2 ORDER BY timestamp ASC @@ -135,6 +144,7 @@ func (q *Queries) ListHistoryByUserSince(ctx context.Context, arg ListHistoryByU if err := rows.Scan( &i.ID, &i.Messages, + &i.Skills, &i.Timestamp, &i.User, ); err != nil { diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index 42146697..5271b577 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -36,6 +36,7 @@ type ContainerVersion struct { type History struct { ID pgtype.UUID `json:"id"` Messages []byte `json:"messages"` + Skills []string `json:"skills"` Timestamp pgtype.Timestamptz `json:"timestamp"` User pgtype.UUID `json:"user"` } diff --git a/internal/history/service.go b/internal/history/service.go index a9200aad..3b356a18 100644 --- a/internal/history/service.go +++ b/internal/history/service.go @@ -39,6 +39,7 @@ func (s *Service) Create(ctx context.Context, userID string, req CreateRequest) } row, err := s.queries.CreateHistory(ctx, sqlc.CreateHistoryParams{ Messages: payload, + Skills: normalizeSkills(req.Skills), Timestamp: pgtype.Timestamptz{ Time: time.Now().UTC(), Valid: true, @@ -117,6 +118,7 @@ func toRecord(row sqlc.History) (Record, error) { } record := Record{ Messages: messages, + Skills: normalizeSkills(row.Skills), } if row.Timestamp.Valid { record.Timestamp = row.Timestamp.Time @@ -136,6 +138,23 @@ func toRecord(row sqlc.History) (Record, error) { return record, nil } +func normalizeSkills(skills []string) []string { + seen := map[string]struct{}{} + normalized := make([]string, 0, len(skills)) + for _, skill := range skills { + trimmed := strings.TrimSpace(skill) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + normalized = append(normalized, trimmed) + } + return normalized +} + func parseUUID(id string) (pgtype.UUID, error) { parsed, err := uuid.Parse(strings.TrimSpace(id)) if err != nil { diff --git a/internal/history/types.go b/internal/history/types.go index 72165388..bc25f2c4 100644 --- a/internal/history/types.go +++ b/internal/history/types.go @@ -5,12 +5,14 @@ import "time" type Record struct { ID string `json:"id"` Messages []map[string]interface{} `json:"messages"` + Skills []string `json:"skills"` Timestamp time.Time `json:"timestamp"` UserID string `json:"user_id"` } type CreateRequest struct { Messages []map[string]interface{} `json:"messages"` + Skills []string `json:"skills,omitempty"` } type ListResponse struct {