diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 56052a81..f67636ed 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -23,6 +23,7 @@ import ( "github.com/memohai/memoh/internal/schedule" "github.com/memohai/memoh/internal/settings" "github.com/memohai/memoh/internal/server" + "github.com/memohai/memoh/internal/subagent" "github.com/jackc/pgx/v5/pgtype" "golang.org/x/crypto/bcrypt" @@ -168,7 +169,9 @@ func main() { log.Fatalf("schedule bootstrap: %v", err) } scheduleHandler := handlers.NewScheduleHandler(scheduleService) - srv := server.NewServer(addr, cfg.Auth.JWTSecret, pingHandler, authHandler, memoryHandler, embeddingsHandler, chatHandler, swaggerHandler, providersHandler, modelsHandler, settingsHandler, historyHandler, scheduleHandler, containerdHandler) + subagentService := subagent.NewService(queries) + subagentHandler := handlers.NewSubagentHandler(subagentService) + srv := server.NewServer(addr, cfg.Auth.JWTSecret, pingHandler, authHandler, memoryHandler, embeddingsHandler, chatHandler, swaggerHandler, providersHandler, modelsHandler, settingsHandler, historyHandler, scheduleHandler, subagentHandler, containerdHandler) if err := srv.Start(); err != nil { log.Fatalf("server failed: %v", err) diff --git a/db/migrations/0001_init.down.sql b/db/migrations/0001_init.down.sql index d76577b2..b62f8229 100644 --- a/db/migrations/0001_init.down.sql +++ b/db/migrations/0001_init.down.sql @@ -1,6 +1,7 @@ DROP TABLE IF EXISTS user_settings; DROP TABLE IF EXISTS history; DROP TABLE IF EXISTS schedule; +DROP TABLE IF EXISTS subagents; DROP TABLE IF EXISTS lifecycle_events; DROP TABLE IF EXISTS container_versions; DROP TABLE IF EXISTS models; diff --git a/db/migrations/0001_init.up.sql b/db/migrations/0001_init.up.sql index 89d0e270..49d1f011 100644 --- a/db/migrations/0001_init.up.sql +++ b/db/migrations/0001_init.up.sql @@ -163,3 +163,21 @@ CREATE TABLE IF NOT EXISTS schedule ( CREATE INDEX IF NOT EXISTS idx_schedule_user_id ON schedule(user_id); CREATE INDEX IF NOT EXISTS idx_schedule_enabled ON schedule(enabled); + +CREATE TABLE IF NOT EXISTS subagents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT false, + deleted_at TIMESTAMPTZ, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + messages JSONB NOT NULL DEFAULT '[]'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + skills JSONB NOT NULL DEFAULT '[]'::jsonb, + CONSTRAINT subagents_name_unique UNIQUE (name) +); + +CREATE INDEX IF NOT EXISTS idx_subagents_user_id ON subagents(user_id); +CREATE INDEX IF NOT EXISTS idx_subagents_deleted ON subagents(deleted); diff --git a/db/queries/subagents.sql b/db/queries/subagents.sql new file mode 100644 index 00000000..1d5f09d1 --- /dev/null +++ b/db/queries/subagents.sql @@ -0,0 +1,46 @@ +-- name: CreateSubagent :one +INSERT INTO subagents (name, description, user_id, messages, metadata, skills) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills; + +-- name: GetSubagentByID :one +SELECT id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +FROM subagents +WHERE id = $1 AND deleted = false; + +-- name: ListSubagentsByUser :many +SELECT id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +FROM subagents +WHERE user_id = $1 AND deleted = false +ORDER BY created_at DESC; + +-- name: UpdateSubagent :one +UPDATE subagents +SET name = $2, + description = $3, + metadata = $4, + updated_at = now() +WHERE id = $1 AND deleted = false +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills; + +-- name: UpdateSubagentMessages :one +UPDATE subagents +SET messages = $2, + updated_at = now() +WHERE id = $1 AND deleted = false +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills; + +-- name: UpdateSubagentSkills :one +UPDATE subagents +SET skills = $2, + updated_at = now() +WHERE id = $1 AND deleted = false +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills; + +-- name: SoftDeleteSubagent :exec +UPDATE subagents +SET deleted = true, + deleted_at = now(), + updated_at = now() +WHERE id = $1 AND deleted = false; + diff --git a/docs/docs.go b/docs/docs.go index 1abeec7a..8a5cf17a 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1921,6 +1921,448 @@ const docTemplate = `{ } } } + }, + "/subagents": { + "get": { + "description": "List subagents for current user", + "tags": [ + "subagent" + ], + "summary": "List subagents", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.ListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a subagent for current user", + "tags": [ + "subagent" + ], + "summary": "Create subagent", + "parameters": [ + { + "description": "Subagent payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.CreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/subagent.Subagent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/subagents/{id}": { + "get": { + "description": "Get a subagent by ID", + "tags": [ + "subagent" + ], + "summary": "Get subagent", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.Subagent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update a subagent by ID", + "tags": [ + "subagent" + ], + "summary": "Update subagent", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Subagent payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.UpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.Subagent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a subagent by ID", + "tags": [ + "subagent" + ], + "summary": "Delete subagent", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/subagents/{id}/context": { + "get": { + "description": "Get a subagent's message context", + "tags": [ + "subagent" + ], + "summary": "Get subagent context", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.ContextResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update a subagent's message context", + "tags": [ + "subagent" + ], + "summary": "Update subagent context", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Context payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.UpdateContextRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.ContextResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/subagents/{id}/skills": { + "get": { + "description": "Get a subagent's skills", + "tags": [ + "subagent" + ], + "summary": "Get subagent skills", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Replace a subagent's skills", + "tags": [ + "subagent" + ], + "summary": "Update subagent skills", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.UpdateSkillsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Add skills to a subagent", + "tags": [ + "subagent" + ], + "summary": "Add subagent skills", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.AddSkillsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } } }, "definitions": { @@ -2775,6 +3217,163 @@ const docTemplate = `{ "type": "integer" } } + }, + "subagent.AddSkillsRequest": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "subagent.ContextResponse": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "subagent.CreateRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "subagent.ListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/subagent.Subagent" + } + } + } + }, + "subagent.SkillsResponse": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "subagent.Subagent": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "deleted": { + "type": "boolean" + }, + "deleted_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "subagent.UpdateContextRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "subagent.UpdateRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + } + } + }, + "subagent.UpdateSkillsRequest": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } } } }` diff --git a/docs/swagger.json b/docs/swagger.json index cbb90be5..92a3c711 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1912,6 +1912,448 @@ } } } + }, + "/subagents": { + "get": { + "description": "List subagents for current user", + "tags": [ + "subagent" + ], + "summary": "List subagents", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.ListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a subagent for current user", + "tags": [ + "subagent" + ], + "summary": "Create subagent", + "parameters": [ + { + "description": "Subagent payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.CreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/subagent.Subagent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/subagents/{id}": { + "get": { + "description": "Get a subagent by ID", + "tags": [ + "subagent" + ], + "summary": "Get subagent", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.Subagent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update a subagent by ID", + "tags": [ + "subagent" + ], + "summary": "Update subagent", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Subagent payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.UpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.Subagent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a subagent by ID", + "tags": [ + "subagent" + ], + "summary": "Delete subagent", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/subagents/{id}/context": { + "get": { + "description": "Get a subagent's message context", + "tags": [ + "subagent" + ], + "summary": "Get subagent context", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.ContextResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update a subagent's message context", + "tags": [ + "subagent" + ], + "summary": "Update subagent context", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Context payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.UpdateContextRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.ContextResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/subagents/{id}/skills": { + "get": { + "description": "Get a subagent's skills", + "tags": [ + "subagent" + ], + "summary": "Get subagent skills", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Replace a subagent's skills", + "tags": [ + "subagent" + ], + "summary": "Update subagent skills", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.UpdateSkillsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Add skills to a subagent", + "tags": [ + "subagent" + ], + "summary": "Add subagent skills", + "parameters": [ + { + "type": "string", + "description": "Subagent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/subagent.AddSkillsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/subagent.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } } }, "definitions": { @@ -2766,6 +3208,163 @@ "type": "integer" } } + }, + "subagent.AddSkillsRequest": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "subagent.ContextResponse": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "subagent.CreateRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "subagent.ListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/subagent.Subagent" + } + } + } + }, + "subagent.SkillsResponse": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "subagent.Subagent": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "deleted": { + "type": "boolean" + }, + "deleted_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "subagent.UpdateContextRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "subagent.UpdateRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "name": { + "type": "string" + } + } + }, + "subagent.UpdateSkillsRequest": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 28e762d9..04608597 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -563,6 +563,110 @@ definitions: max_context_load_time: type: integer type: object + subagent.AddSkillsRequest: + properties: + skills: + items: + type: string + type: array + type: object + subagent.ContextResponse: + properties: + messages: + items: + additionalProperties: true + type: object + type: array + type: object + subagent.CreateRequest: + properties: + description: + type: string + messages: + items: + additionalProperties: true + type: object + type: array + metadata: + additionalProperties: true + type: object + name: + type: string + skills: + items: + type: string + type: array + type: object + subagent.ListResponse: + properties: + items: + items: + $ref: '#/definitions/subagent.Subagent' + type: array + type: object + subagent.SkillsResponse: + properties: + skills: + items: + type: string + type: array + type: object + subagent.Subagent: + properties: + created_at: + type: string + deleted: + type: boolean + deleted_at: + type: string + description: + type: string + id: + type: string + messages: + items: + additionalProperties: true + type: object + type: array + metadata: + additionalProperties: true + type: object + name: + type: string + skills: + items: + type: string + type: array + updated_at: + type: string + user_id: + type: string + type: object + subagent.UpdateContextRequest: + properties: + messages: + items: + additionalProperties: true + type: object + type: array + type: object + subagent.UpdateRequest: + properties: + description: + type: string + metadata: + additionalProperties: true + type: object + name: + type: string + type: object + subagent.UpdateSkillsRequest: + properties: + skills: + items: + type: string + type: array + type: object info: contact: {} title: Memoh API @@ -1828,4 +1932,297 @@ paths: summary: Update user settings tags: - settings + /subagents: + get: + description: List subagents for current user + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.ListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: List subagents + tags: + - subagent + post: + description: Create a subagent for current user + parameters: + - description: Subagent payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/subagent.CreateRequest' + responses: + "201": + description: Created + schema: + $ref: '#/definitions/subagent.Subagent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Create subagent + tags: + - subagent + /subagents/{id}: + delete: + description: Delete a subagent by ID + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Delete subagent + tags: + - subagent + get: + description: Get a subagent by ID + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.Subagent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Get subagent + tags: + - subagent + put: + description: Update a subagent by ID + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + - description: Subagent payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/subagent.UpdateRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.Subagent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Update subagent + tags: + - subagent + /subagents/{id}/context: + get: + description: Get a subagent's message context + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.ContextResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Get subagent context + tags: + - subagent + put: + description: Update a subagent's message context + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + - description: Context payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/subagent.UpdateContextRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.ContextResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Update subagent context + tags: + - subagent + /subagents/{id}/skills: + get: + description: Get a subagent's skills + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.SkillsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Get subagent skills + tags: + - subagent + post: + description: Add skills to a subagent + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + - description: Skills payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/subagent.AddSkillsRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.SkillsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Add subagent skills + tags: + - subagent + put: + description: Replace a subagent's skills + parameters: + - description: Subagent ID + in: path + name: id + required: true + type: string + - description: Skills payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/subagent.UpdateSkillsRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/subagent.SkillsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Update subagent skills + tags: + - subagent swagger: "2.0" diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index 0cf39b66..42146697 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -105,6 +105,20 @@ type Snapshot struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Subagent struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Deleted bool `json:"deleted"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + UserID pgtype.UUID `json:"user_id"` + Messages []byte `json:"messages"` + Metadata []byte `json:"metadata"` + Skills []byte `json:"skills"` +} + type User struct { ID pgtype.UUID `json:"id"` Username string `json:"username"` diff --git a/internal/db/sqlc/subagents.sql.go b/internal/db/sqlc/subagents.sql.go new file mode 100644 index 00000000..66cf3fee --- /dev/null +++ b/internal/db/sqlc/subagents.sql.go @@ -0,0 +1,235 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: subagents.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createSubagent = `-- name: CreateSubagent :one +INSERT INTO subagents (name, description, user_id, messages, metadata, skills) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +` + +type CreateSubagentParams struct { + Name string `json:"name"` + Description string `json:"description"` + UserID pgtype.UUID `json:"user_id"` + Messages []byte `json:"messages"` + Metadata []byte `json:"metadata"` + Skills []byte `json:"skills"` +} + +func (q *Queries) CreateSubagent(ctx context.Context, arg CreateSubagentParams) (Subagent, error) { + row := q.db.QueryRow(ctx, createSubagent, + arg.Name, + arg.Description, + arg.UserID, + arg.Messages, + arg.Metadata, + arg.Skills, + ) + var i Subagent + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.Deleted, + &i.DeletedAt, + &i.UserID, + &i.Messages, + &i.Metadata, + &i.Skills, + ) + return i, err +} + +const getSubagentByID = `-- name: GetSubagentByID :one +SELECT id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +FROM subagents +WHERE id = $1 AND deleted = false +` + +func (q *Queries) GetSubagentByID(ctx context.Context, id pgtype.UUID) (Subagent, error) { + row := q.db.QueryRow(ctx, getSubagentByID, id) + var i Subagent + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.Deleted, + &i.DeletedAt, + &i.UserID, + &i.Messages, + &i.Metadata, + &i.Skills, + ) + return i, err +} + +const listSubagentsByUser = `-- name: ListSubagentsByUser :many +SELECT id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +FROM subagents +WHERE user_id = $1 AND deleted = false +ORDER BY created_at DESC +` + +func (q *Queries) ListSubagentsByUser(ctx context.Context, userID pgtype.UUID) ([]Subagent, error) { + rows, err := q.db.Query(ctx, listSubagentsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Subagent + for rows.Next() { + var i Subagent + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.Deleted, + &i.DeletedAt, + &i.UserID, + &i.Messages, + &i.Metadata, + &i.Skills, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const softDeleteSubagent = `-- name: SoftDeleteSubagent :exec +UPDATE subagents +SET deleted = true, + deleted_at = now(), + updated_at = now() +WHERE id = $1 AND deleted = false +` + +func (q *Queries) SoftDeleteSubagent(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteSubagent, id) + return err +} + +const updateSubagent = `-- name: UpdateSubagent :one +UPDATE subagents +SET name = $2, + description = $3, + metadata = $4, + updated_at = now() +WHERE id = $1 AND deleted = false +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +` + +type UpdateSubagentParams struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) UpdateSubagent(ctx context.Context, arg UpdateSubagentParams) (Subagent, error) { + row := q.db.QueryRow(ctx, updateSubagent, + arg.ID, + arg.Name, + arg.Description, + arg.Metadata, + ) + var i Subagent + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.Deleted, + &i.DeletedAt, + &i.UserID, + &i.Messages, + &i.Metadata, + &i.Skills, + ) + return i, err +} + +const updateSubagentMessages = `-- name: UpdateSubagentMessages :one +UPDATE subagents +SET messages = $2, + updated_at = now() +WHERE id = $1 AND deleted = false +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +` + +type UpdateSubagentMessagesParams struct { + ID pgtype.UUID `json:"id"` + Messages []byte `json:"messages"` +} + +func (q *Queries) UpdateSubagentMessages(ctx context.Context, arg UpdateSubagentMessagesParams) (Subagent, error) { + row := q.db.QueryRow(ctx, updateSubagentMessages, arg.ID, arg.Messages) + var i Subagent + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.Deleted, + &i.DeletedAt, + &i.UserID, + &i.Messages, + &i.Metadata, + &i.Skills, + ) + return i, err +} + +const updateSubagentSkills = `-- name: UpdateSubagentSkills :one +UPDATE subagents +SET skills = $2, + updated_at = now() +WHERE id = $1 AND deleted = false +RETURNING id, name, description, created_at, updated_at, deleted, deleted_at, user_id, messages, metadata, skills +` + +type UpdateSubagentSkillsParams struct { + ID pgtype.UUID `json:"id"` + Skills []byte `json:"skills"` +} + +func (q *Queries) UpdateSubagentSkills(ctx context.Context, arg UpdateSubagentSkillsParams) (Subagent, error) { + row := q.db.QueryRow(ctx, updateSubagentSkills, arg.ID, arg.Skills) + var i Subagent + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.Deleted, + &i.DeletedAt, + &i.UserID, + &i.Messages, + &i.Metadata, + &i.Skills, + ) + return i, err +} diff --git a/internal/handlers/subagent.go b/internal/handlers/subagent.go new file mode 100644 index 00000000..e4b50d03 --- /dev/null +++ b/internal/handlers/subagent.go @@ -0,0 +1,361 @@ +package handlers + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/memohai/memoh/internal/auth" + "github.com/memohai/memoh/internal/identity" + "github.com/memohai/memoh/internal/subagent" +) + +type SubagentHandler struct { + service *subagent.Service +} + +func NewSubagentHandler(service *subagent.Service) *SubagentHandler { + return &SubagentHandler{service: service} +} + +func (h *SubagentHandler) Register(e *echo.Echo) { + group := e.Group("/subagents") + group.POST("", h.Create) + group.GET("", h.List) + group.GET("/:id", h.Get) + group.PUT("/:id", h.Update) + group.DELETE("/:id", h.Delete) + group.GET("/:id/context", h.GetContext) + group.PUT("/:id/context", h.UpdateContext) + group.GET("/:id/skills", h.GetSkills) + group.PUT("/:id/skills", h.UpdateSkills) + group.POST("/:id/skills", h.AddSkills) +} + +// Create godoc +// @Summary Create subagent +// @Description Create a subagent for current user +// @Tags subagent +// @Param payload body subagent.CreateRequest true "Subagent payload" +// @Success 201 {object} subagent.Subagent +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents [post] +func (h *SubagentHandler) Create(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + var req subagent.CreateRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + resp, err := h.service.Create(c.Request().Context(), userID, req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusCreated, resp) +} + +// List godoc +// @Summary List subagents +// @Description List subagents for current user +// @Tags subagent +// @Success 200 {object} subagent.ListResponse +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents [get] +func (h *SubagentHandler) List(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + items, err := h.service.List(c.Request().Context(), userID) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, subagent.ListResponse{Items: items}) +} + +// Get godoc +// @Summary Get subagent +// @Description Get a subagent by ID +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Success 200 {object} subagent.Subagent +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id} [get] +func (h *SubagentHandler) Get(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + return c.JSON(http.StatusOK, item) +} + +// Update godoc +// @Summary Update subagent +// @Description Update a subagent by ID +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Param payload body subagent.UpdateRequest true "Subagent payload" +// @Success 200 {object} subagent.Subagent +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id} [put] +func (h *SubagentHandler) Update(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + var req subagent.UpdateRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + resp, err := h.service.Update(c.Request().Context(), id, req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, resp) +} + +// Delete godoc +// @Summary Delete subagent +// @Description Delete a subagent by ID +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Success 204 "No Content" +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id} [delete] +func (h *SubagentHandler) Delete(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + if err := h.service.Delete(c.Request().Context(), id); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.NoContent(http.StatusNoContent) +} + +// GetContext godoc +// @Summary Get subagent context +// @Description Get a subagent's message context +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Success 200 {object} subagent.ContextResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id}/context [get] +func (h *SubagentHandler) GetContext(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + return c.JSON(http.StatusOK, subagent.ContextResponse{Messages: item.Messages}) +} + +// UpdateContext godoc +// @Summary Update subagent context +// @Description Update a subagent's message context +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Param payload body subagent.UpdateContextRequest true "Context payload" +// @Success 200 {object} subagent.ContextResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id}/context [put] +func (h *SubagentHandler) UpdateContext(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + var req subagent.UpdateContextRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + updated, err := h.service.UpdateContext(c.Request().Context(), id, req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, subagent.ContextResponse{Messages: updated.Messages}) +} + +// GetSkills godoc +// @Summary Get subagent skills +// @Description Get a subagent's skills +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Success 200 {object} subagent.SkillsResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id}/skills [get] +func (h *SubagentHandler) GetSkills(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + return c.JSON(http.StatusOK, subagent.SkillsResponse{Skills: item.Skills}) +} + +// UpdateSkills godoc +// @Summary Update subagent skills +// @Description Replace a subagent's skills +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Param payload body subagent.UpdateSkillsRequest true "Skills payload" +// @Success 200 {object} subagent.SkillsResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id}/skills [put] +func (h *SubagentHandler) UpdateSkills(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + var req subagent.UpdateSkillsRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + updated, err := h.service.UpdateSkills(c.Request().Context(), id, req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, subagent.SkillsResponse{Skills: updated.Skills}) +} + +// AddSkills godoc +// @Summary Add subagent skills +// @Description Add skills to a subagent +// @Tags subagent +// @Param id path string true "Subagent ID" +// @Param payload body subagent.AddSkillsRequest true "Skills payload" +// @Success 200 {object} subagent.SkillsResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /subagents/{id}/skills [post] +func (h *SubagentHandler) AddSkills(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + id := c.Param("id") + if id == "" { + return echo.NewHTTPError(http.StatusBadRequest, "id is required") + } + var req subagent.AddSkillsRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + item, err := h.service.Get(c.Request().Context(), id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + if item.UserID != userID { + return echo.NewHTTPError(http.StatusForbidden, "user mismatch") + } + updated, err := h.service.AddSkills(c.Request().Context(), id, req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, subagent.SkillsResponse{Skills: updated.Skills}) +} + +func (h *SubagentHandler) requireUserID(c echo.Context) (string, error) { + userID, err := auth.UserIDFromContext(c) + if err != nil { + return "", err + } + if err := identity.ValidateUserID(userID); err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + return userID, nil +} + diff --git a/internal/server/server.go b/internal/server/server.go index 8becfced..706e5f64 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,7 +15,7 @@ type Server struct { addr string } -func NewServer(addr string, jwtSecret string, pingHandler *handlers.PingHandler, authHandler *handlers.AuthHandler, memoryHandler *handlers.MemoryHandler, embeddingsHandler *handlers.EmbeddingsHandler, chatHandler *handlers.ChatHandler, swaggerHandler *handlers.SwaggerHandler, providersHandler *handlers.ProvidersHandler, modelsHandler *handlers.ModelsHandler, settingsHandler *handlers.SettingsHandler, historyHandler *handlers.HistoryHandler, scheduleHandler *handlers.ScheduleHandler, containerdHandler *handlers.ContainerdHandler) *Server { +func NewServer(addr string, jwtSecret string, pingHandler *handlers.PingHandler, authHandler *handlers.AuthHandler, memoryHandler *handlers.MemoryHandler, embeddingsHandler *handlers.EmbeddingsHandler, chatHandler *handlers.ChatHandler, swaggerHandler *handlers.SwaggerHandler, providersHandler *handlers.ProvidersHandler, modelsHandler *handlers.ModelsHandler, settingsHandler *handlers.SettingsHandler, historyHandler *handlers.HistoryHandler, scheduleHandler *handlers.ScheduleHandler, subagentHandler *handlers.SubagentHandler, containerdHandler *handlers.ContainerdHandler) *Server { if addr == "" { addr = ":8080" } @@ -65,6 +65,9 @@ func NewServer(addr string, jwtSecret string, pingHandler *handlers.PingHandler, if scheduleHandler != nil { scheduleHandler.Register(e) } + if subagentHandler != nil { + subagentHandler.Register(e) + } if providersHandler != nil { providersHandler.Register(e) } diff --git a/internal/subagent/service.go b/internal/subagent/service.go new file mode 100644 index 00000000..0e743e78 --- /dev/null +++ b/internal/subagent/service.go @@ -0,0 +1,355 @@ +package subagent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/memohai/memoh/internal/db/sqlc" +) + +type Service struct { + queries *sqlc.Queries +} + +func NewService(queries *sqlc.Queries) *Service { + return &Service{queries: queries} +} + +func (s *Service) Create(ctx context.Context, userID string, req CreateRequest) (Subagent, error) { + if s.queries == nil { + return Subagent{}, fmt.Errorf("subagent queries not configured") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return Subagent{}, fmt.Errorf("name is required") + } + description := strings.TrimSpace(req.Description) + if description == "" { + return Subagent{}, fmt.Errorf("description is required") + } + pgUserID, err := parseUUID(userID) + if err != nil { + return Subagent{}, err + } + messagesPayload, err := marshalMessages(req.Messages) + if err != nil { + return Subagent{}, err + } + metadataPayload, err := marshalMetadata(req.Metadata) + if err != nil { + return Subagent{}, err + } + skillsPayload, err := marshalSkills(req.Skills) + if err != nil { + return Subagent{}, err + } + row, err := s.queries.CreateSubagent(ctx, sqlc.CreateSubagentParams{ + Name: name, + Description: description, + UserID: pgUserID, + Messages: messagesPayload, + Metadata: metadataPayload, + Skills: skillsPayload, + }) + if err != nil { + return Subagent{}, err + } + return toSubagent(row) +} + +func (s *Service) Get(ctx context.Context, id string) (Subagent, error) { + pgID, err := parseUUID(id) + if err != nil { + return Subagent{}, err + } + row, err := s.queries.GetSubagentByID(ctx, pgID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Subagent{}, fmt.Errorf("subagent not found") + } + return Subagent{}, err + } + return toSubagent(row) +} + +func (s *Service) List(ctx context.Context, userID string) ([]Subagent, error) { + pgUserID, err := parseUUID(userID) + if err != nil { + return nil, err + } + rows, err := s.queries.ListSubagentsByUser(ctx, pgUserID) + if err != nil { + return nil, err + } + items := make([]Subagent, 0, len(rows)) + for _, row := range rows { + item, err := toSubagent(row) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Subagent, error) { + existing, err := s.Get(ctx, id) + if err != nil { + return Subagent{}, err + } + name := existing.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + if name == "" { + return Subagent{}, fmt.Errorf("name is required") + } + } + description := existing.Description + if req.Description != nil { + description = strings.TrimSpace(*req.Description) + if description == "" { + return Subagent{}, fmt.Errorf("description is required") + } + } + metadata := existing.Metadata + if req.Metadata != nil { + metadata = req.Metadata + } + metadataPayload, err := marshalMetadata(metadata) + if err != nil { + return Subagent{}, err + } + pgID, err := parseUUID(id) + if err != nil { + return Subagent{}, err + } + row, err := s.queries.UpdateSubagent(ctx, sqlc.UpdateSubagentParams{ + ID: pgID, + Name: name, + Description: description, + Metadata: metadataPayload, + }) + if err != nil { + return Subagent{}, err + } + return toSubagent(row) +} + +func (s *Service) UpdateContext(ctx context.Context, id string, req UpdateContextRequest) (Subagent, error) { + messagesPayload, err := marshalMessages(req.Messages) + if err != nil { + return Subagent{}, err + } + pgID, err := parseUUID(id) + if err != nil { + return Subagent{}, err + } + row, err := s.queries.UpdateSubagentMessages(ctx, sqlc.UpdateSubagentMessagesParams{ + ID: pgID, + Messages: messagesPayload, + }) + if err != nil { + return Subagent{}, err + } + return toSubagent(row) +} + +func (s *Service) UpdateSkills(ctx context.Context, id string, req UpdateSkillsRequest) (Subagent, error) { + skillsPayload, err := marshalSkills(req.Skills) + if err != nil { + return Subagent{}, err + } + pgID, err := parseUUID(id) + if err != nil { + return Subagent{}, err + } + row, err := s.queries.UpdateSubagentSkills(ctx, sqlc.UpdateSubagentSkillsParams{ + ID: pgID, + Skills: skillsPayload, + }) + if err != nil { + return Subagent{}, err + } + return toSubagent(row) +} + +func (s *Service) AddSkills(ctx context.Context, id string, req AddSkillsRequest) (Subagent, error) { + existing, err := s.Get(ctx, id) + if err != nil { + return Subagent{}, err + } + merged := mergeSkills(existing.Skills, req.Skills) + payload, err := marshalSkills(merged) + if err != nil { + return Subagent{}, err + } + pgID, err := parseUUID(id) + if err != nil { + return Subagent{}, err + } + row, err := s.queries.UpdateSubagentSkills(ctx, sqlc.UpdateSubagentSkillsParams{ + ID: pgID, + Skills: payload, + }) + if err != nil { + return Subagent{}, err + } + return toSubagent(row) +} + +func (s *Service) Delete(ctx context.Context, id string) error { + pgID, err := parseUUID(id) + if err != nil { + return err + } + return s.queries.SoftDeleteSubagent(ctx, pgID) +} + +func toSubagent(row sqlc.Subagent) (Subagent, error) { + messages, err := unmarshalMessages(row.Messages) + if err != nil { + return Subagent{}, err + } + metadata, err := unmarshalMetadata(row.Metadata) + if err != nil { + return Subagent{}, err + } + skills, err := unmarshalSkills(row.Skills) + if err != nil { + return Subagent{}, err + } + item := Subagent{ + ID: toUUIDString(row.ID), + Name: row.Name, + Description: row.Description, + UserID: toUUIDString(row.UserID), + Messages: messages, + Metadata: metadata, + Skills: skills, + Deleted: row.Deleted, + } + if row.CreatedAt.Valid { + item.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + item.UpdatedAt = row.UpdatedAt.Time + } + if row.DeletedAt.Valid { + deletedAt := row.DeletedAt.Time + item.DeletedAt = &deletedAt + } + return item, nil +} + +func marshalMessages(messages []map[string]interface{}) ([]byte, error) { + if messages == nil { + messages = []map[string]interface{}{} + } + return json.Marshal(messages) +} + +func unmarshalMessages(payload []byte) ([]map[string]interface{}, error) { + if len(payload) == 0 { + return []map[string]interface{}{}, nil + } + var messages []map[string]interface{} + if err := json.Unmarshal(payload, &messages); err != nil { + return nil, err + } + if messages == nil { + messages = []map[string]interface{}{} + } + return messages, nil +} + +func marshalMetadata(metadata map[string]interface{}) ([]byte, error) { + if metadata == nil { + metadata = map[string]interface{}{} + } + return json.Marshal(metadata) +} + +func unmarshalMetadata(payload []byte) (map[string]interface{}, error) { + if len(payload) == 0 { + return map[string]interface{}{}, nil + } + var metadata map[string]interface{} + if err := json.Unmarshal(payload, &metadata); err != nil { + return nil, err + } + if metadata == nil { + metadata = map[string]interface{}{} + } + return metadata, nil +} + +func marshalSkills(skills []string) ([]byte, error) { + return json.Marshal(normalizeSkills(skills)) +} + +func unmarshalSkills(payload []byte) ([]string, error) { + if len(payload) == 0 { + return []string{}, nil + } + var skills []string + if err := json.Unmarshal(payload, &skills); err != nil { + return nil, err + } + if skills == nil { + skills = []string{} + } + return skills, 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 mergeSkills(existing []string, incoming []string) []string { + merged := append([]string{}, existing...) + merged = append(merged, incoming...) + return normalizeSkills(merged) +} + +func parseUUID(id string) (pgtype.UUID, error) { + parsed, err := uuid.Parse(strings.TrimSpace(id)) + if err != nil { + return pgtype.UUID{}, fmt.Errorf("invalid UUID: %w", err) + } + var pgID pgtype.UUID + pgID.Valid = true + copy(pgID.Bytes[:], parsed[:]) + return pgID, nil +} + +func toUUIDString(value pgtype.UUID) string { + if !value.Valid { + return "" + } + id, err := uuid.FromBytes(value.Bytes[:]) + if err != nil { + return "" + } + return id.String() +} + diff --git a/internal/subagent/types.go b/internal/subagent/types.go new file mode 100644 index 00000000..15e9c63e --- /dev/null +++ b/internal/subagent/types.go @@ -0,0 +1,56 @@ +package subagent + +import "time" + +type Subagent struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + UserID string `json:"user_id"` + Messages []map[string]interface{} `json:"messages"` + Metadata map[string]interface{} `json:"metadata"` + Skills []string `json:"skills"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Deleted bool `json:"deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Messages []map[string]interface{} `json:"messages,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +type UpdateRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type UpdateContextRequest struct { + Messages []map[string]interface{} `json:"messages"` +} + +type UpdateSkillsRequest struct { + Skills []string `json:"skills"` +} + +type AddSkillsRequest struct { + Skills []string `json:"skills"` +} + +type ListResponse struct { + Items []Subagent `json:"items"` +} + +type ContextResponse struct { + Messages []map[string]interface{} `json:"messages"` +} + +type SkillsResponse struct { + Skills []string `json:"skills"` +} +