Przeglądaj źródła

feat: add skill service

Jax Docker 1 miesiąc temu
rodzic
commit
305ec7db4a
33 zmienionych plików z 1364 dodań i 0 usunięć
  1. 54 0
      README.md
  2. 27 0
      deployments/docker/docker-compose.yml
  3. 18 0
      libs/core-domain/src/core_domain/__init__.py
  4. 71 0
      libs/core-domain/src/core_domain/skill_contracts.py
  5. 1 0
      pyproject.toml
  6. 27 0
      services/api-gateway/app/api/routes.py
  7. 1 0
      services/api-gateway/app/bootstrap/settings.py
  8. 1 0
      services/api-gateway/app/infrastructure/proxy.py
  9. 36 0
      services/skill-service/alembic.ini
  10. 38 0
      services/skill-service/alembic/env.py
  11. 1 0
      services/skill-service/alembic/versions/.gitkeep
  12. 163 0
      services/skill-service/alembic/versions/20260425_0001_init_skill_models.py
  13. 1 0
      services/skill-service/app/__init__.py
  14. 1 0
      services/skill-service/app/api/__init__.py
  15. 158 0
      services/skill-service/app/api/routes.py
  16. 1 0
      services/skill-service/app/application/__init__.py
  17. 225 0
      services/skill-service/app/application/services.py
  18. 1 0
      services/skill-service/app/bootstrap/__init__.py
  19. 14 0
      services/skill-service/app/bootstrap/app.py
  20. 7 0
      services/skill-service/app/bootstrap/settings.py
  21. 1 0
      services/skill-service/app/db/__init__.py
  22. 8 0
      services/skill-service/app/db/models/__init__.py
  23. 18 0
      services/skill-service/app/db/models/skill_definition.py
  24. 21 0
      services/skill-service/app/db/models/skill_installation.py
  25. 25 0
      services/skill-service/app/db/models/skill_run.py
  26. 22 0
      services/skill-service/app/db/models/skill_version.py
  27. 28 0
      services/skill-service/app/db/session.py
  28. 1 0
      services/skill-service/app/domain/__init__.py
  29. 270 0
      services/skill-service/app/domain/repositories.py
  30. 3 0
      services/skill-service/app/main.py
  31. 1 0
      services/skill-service/app/schemas/__init__.py
  32. 95 0
      services/skill-service/app/schemas/skill.py
  33. 25 0
      services/skill-service/pyproject.toml

+ 54 - 0
README.md

@@ -19,6 +19,7 @@
 - `agent-service`
 - `memory-service`
 - `team-service`
+- `skill-service`
 - `tool-service`
 
 每个服务都提供了最小 `FastAPI` 启动入口和健康检查接口,数据库相关服务也已经带上了 `SQLAlchemy` 模型骨架与 Alembic 目录。
@@ -51,6 +52,7 @@ pip install -e .\services\runtime-service
 pip install -e .\services\agent-service
 pip install -e .\services\memory-service
 pip install -e .\services\team-service
+pip install -e .\services\skill-service
 pip install -e .\services\tool-service
 ```
 
@@ -194,6 +196,7 @@ services/
   session-service/
   workflow-service/
   runtime-service/
+  skill-service/
   tool-service/
 libs/
   core-domain/
@@ -308,6 +311,55 @@ Invoke-RestMethod -Method Post `
 
 Through `api-gateway`, use `/gateway/teams/**`.
 
+## Skill Service APIs
+
+`skill-service` stores reusable skill definitions, versioned parameter/output schemas,
+marketplace-style installations, and executable skill runs. The first executor supports a
+dependency-free `template` runtime so local development works without API keys.
+
+Create a skill:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8010/skills `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","code":"hello_user","name":"Hello User","skill_type":"template"}'
+```
+
+Create a published skill version:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8010/skills/versions `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","skill_id":"skill-id","status":"published","runtime_type":"template","parameter_schema_json":{"name":{"type":"string"}},"implementation_json":{"template":"Hello $name"}}'
+```
+
+Install the skill for a tenant, agent, team, app, or user scope:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8010/skills/installations `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","skill_id":"skill-id","install_scope":"tenant","scope_id":"t1","installed_by":"user-1"}'
+```
+
+Create and execute a skill run:
+
+```powershell
+$run = Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8010/skills/runs `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","skill_id":"skill-id","input_json":{"name":"Lucas"}}'
+
+Invoke-RestMethod -Method Post `
+  -Uri "http://127.0.0.1:8010/skills/runs/$($run.id)/execute" `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","worker_key":"skill-worker-1"}'
+```
+
+Through `api-gateway`, use `/gateway/skills/**`.
+
 Execute an agent run without calling an external model:
 
 ```powershell
@@ -602,6 +654,7 @@ $env:AGENT_PLATFORM_SMOKE_RUNTIME_URL="http://127.0.0.1:8000/gateway/runtime"
 - `/gateway/agents/**` -> `agent-service /agents/**`
 - `/gateway/memories/**` -> `memory-service /memories/**`
 - `/gateway/teams/**` -> `team-service /teams/**`
+- `/gateway/skills/**` -> `skill-service /skills/**`
 - `/gateway/tools/**` -> `tool-service /tools/**`
 - `/gateway/models/**` -> `model-gateway-service /models/**`
 - `/gateway/code/**` -> `code-runner-service /code/**`
@@ -836,6 +889,7 @@ Important notes:
 - `agent-service` stores agent definitions, prompt/config versions, and agent run records under `/data`
 - `memory-service` stores scoped memories under `/data`; move it to PostgreSQL before enabling high-volume memory writes
 - `team-service` stores multi-agent team definitions, team versions, and team run records under `/data`
+- `skill-service` stores skill definitions, versions, marketplace-style installations, and skill execution runs under `/data`
 - `agent-worker` has no exposed port and can be scaled independently; set `AGENT_PLATFORM_AGENT_WORKER_DRY_RUN=true` for no-key local smoke runs
 - `runtime-worker` has no exposed port and can be scaled independently; prefer PostgreSQL for real multi-worker write concurrency
 - `runtime-service` automatically resolves internal URLs to `workflow-service`, `tool-service`, `model-gateway-service`, and `code-runner-service`

+ 27 - 0
deployments/docker/docker-compose.yml

@@ -191,6 +191,26 @@ services:
       timeout: 5s
       retries: 5
 
+  skill-service:
+    build:
+      context: ../..
+      dockerfile: deployments/docker/python-service.Dockerfile
+      args:
+        SERVICE_PATH: services/skill-service
+    container_name: agent-platform-skill-service
+    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]
+    environment:
+      AGENT_PLATFORM_DATABASE_URL: sqlite:////data/skill_service.db
+    ports:
+      - "8010:8010"
+    volumes:
+      - skill_service_data:/data
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/skills/health').read()"]
+      interval: 15s
+      timeout: 5s
+      retries: 5
+
   runtime-service:
     build:
       context: ../..
@@ -208,6 +228,7 @@ services:
       AGENT_PLATFORM_AGENT_SERVICE_URL: http://agent-service:8007
       AGENT_PLATFORM_MEMORY_SERVICE_URL: http://memory-service:8008
       AGENT_PLATFORM_TEAM_SERVICE_URL: http://team-service:8009
+      AGENT_PLATFORM_SKILL_SERVICE_URL: http://skill-service:8010
     ports:
       - "8003:8003"
     volumes:
@@ -227,6 +248,8 @@ services:
         condition: service_started
       team-service:
         condition: service_started
+      skill-service:
+        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8003/runtime/health').read()"]
       interval: 15s
@@ -279,6 +302,7 @@ services:
       AGENT_PLATFORM_AGENT_SERVICE_URL: http://agent-service:8007
       AGENT_PLATFORM_MEMORY_SERVICE_URL: http://memory-service:8008
       AGENT_PLATFORM_TEAM_SERVICE_URL: http://team-service:8009
+      AGENT_PLATFORM_SKILL_SERVICE_URL: http://skill-service:8010
       AGENT_PLATFORM_AUTH_REQUIRED: ${AGENT_PLATFORM_AUTH_REQUIRED:-false}
     ports:
       - "8000:8000"
@@ -303,6 +327,8 @@ services:
         condition: service_started
       team-service:
         condition: service_started
+      skill-service:
+        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
       interval: 15s
@@ -314,6 +340,7 @@ volumes:
   agent_service_data:
   memory_service_data:
   team_service_data:
+  skill_service_data:
   workflow_service_data:
   session_service_data:
   runtime_service_data:

+ 18 - 0
libs/core-domain/src/core_domain/__init__.py

@@ -42,6 +42,16 @@ from .runtime_contracts import (
     WorkflowRunContract,
 )
 from .service import ServiceDescriptor, ServiceHealth
+from .skill_contracts import (
+    SkillDefinitionContract,
+    SkillInstallationContract,
+    SkillInstallStatus,
+    SkillRunContract,
+    SkillRunStatus,
+    SkillStatus,
+    SkillVersionContract,
+    SkillVersionStatus,
+)
 from .team_contracts import (
     TeamDefinitionContract,
     TeamMemberContract,
@@ -94,6 +104,14 @@ __all__ = [
     "RunCreateContract",
     "ServiceDescriptor",
     "ServiceHealth",
+    "SkillDefinitionContract",
+    "SkillInstallationContract",
+    "SkillInstallStatus",
+    "SkillRunContract",
+    "SkillRunStatus",
+    "SkillStatus",
+    "SkillVersionContract",
+    "SkillVersionStatus",
     "TeamDefinitionContract",
     "TeamMemberContract",
     "TeamMemberRole",

+ 71 - 0
libs/core-domain/src/core_domain/skill_contracts.py

@@ -0,0 +1,71 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from core_shared import JSONValue
+
+
+SkillStatus = Literal["draft", "active", "archived"]
+SkillVersionStatus = Literal["draft", "published", "deprecated"]
+SkillInstallStatus = Literal["installed", "disabled", "uninstalled"]
+SkillRunStatus = Literal["queued", "running", "completed", "failed", "cancelled"]
+
+
+class SkillDefinitionContract(BaseModel):
+    id: str
+    tenant_id: str
+    code: str
+    name: str
+    skill_type: str
+    description: str | None = None
+    status: SkillStatus
+    owner_user_id: str | None = None
+    created_time: datetime
+
+
+class SkillVersionContract(BaseModel):
+    id: str
+    tenant_id: str
+    skill_id: str
+    version_no: int
+    status: SkillVersionStatus
+    runtime_type: str
+    entrypoint: str | None = None
+    parameter_schema_json: dict[str, JSONValue]
+    output_schema_json: dict[str, JSONValue]
+    implementation_json: dict[str, JSONValue]
+    published_time: datetime | None = None
+    created_time: datetime
+
+
+class SkillInstallationContract(BaseModel):
+    id: str
+    tenant_id: str
+    skill_id: str
+    skill_version_id: str
+    install_scope: str
+    scope_id: str
+    status: SkillInstallStatus
+    config_json: dict[str, JSONValue]
+    installed_by: str | None = None
+    installed_time: datetime | None = None
+    created_time: datetime
+
+
+class SkillRunContract(BaseModel):
+    id: str
+    tenant_id: str
+    skill_id: str
+    skill_version_id: str
+    installation_id: str | None = None
+    status: SkillRunStatus
+    input_json: dict[str, JSONValue] = Field(default_factory=dict)
+    output_json: dict[str, JSONValue] | None = None
+    output_text: str | None = None
+    worker_key: str | None = None
+    started_time: datetime | None = None
+    finished_time: datetime | None = None
+    error_code: str | None = None
+    error_message: str | None = None
+    created_time: datetime

+ 1 - 0
pyproject.toml

@@ -11,6 +11,7 @@ members = [
   "services/memory-service",
   "services/model-gateway-service",
   "services/session-service",
+  "services/skill-service",
   "services/team-service",
   "services/workflow-service",
   "services/runtime-service",

+ 27 - 0
services/api-gateway/app/api/routes.py

@@ -169,6 +169,12 @@ def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName,
             path_prefix="/teams",
             health_path="/teams/health",
         ),
+        "skill-service": ProxyTarget(
+            service_name="skill-service",
+            base_url=settings.skill_service_url,
+            path_prefix="/skills",
+            health_path="/skills/health",
+        ),
     }
 
 
@@ -314,6 +320,27 @@ async def proxy_team_service(
     )
 
 
+@router.api_route(
+    "/gateway/skills",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+@router.api_route(
+    "/gateway/skills/{path:path}",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+async def proxy_skill_service(
+    request: Request,
+    path: str = "",
+    settings: ApiGatewaySettings = Depends(get_gateway_settings),
+    proxy: ServiceProxy = Depends(get_service_proxy),
+) -> Response:
+    return await proxy.forward(
+        request=request,
+        target=build_proxy_targets(settings)["skill-service"],
+        path=path,
+    )
+
+
 @router.api_route(
     "/gateway/tools",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"],

+ 1 - 0
services/api-gateway/app/bootstrap/settings.py

@@ -14,6 +14,7 @@ class ApiGatewaySettings(ServiceSettings):
     agent_service_url: str = "http://127.0.0.1:8007"
     memory_service_url: str = "http://127.0.0.1:8008"
     team_service_url: str = "http://127.0.0.1:8009"
+    skill_service_url: str = "http://127.0.0.1:8010"
     proxy_timeout_seconds: float = 30.0
     downstream_health_timeout_seconds: float = 2.0
     auth_required: bool = False

+ 1 - 0
services/api-gateway/app/infrastructure/proxy.py

@@ -18,6 +18,7 @@ ProxyServiceName = Literal[
     "agent-service",
     "memory-service",
     "team-service",
+    "skill-service",
 ]
 
 

+ 36 - 0
services/skill-service/alembic.ini

@@ -0,0 +1,36 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = sqlite:///./skill_service.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers = console
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s

+ 38 - 0
services/skill-service/alembic/env.py

@@ -0,0 +1,38 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.db.models import Base
+
+config = context.config
+
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+    with connectable.connect() as connection:
+        context.configure(connection=connection, target_metadata=target_metadata)
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 1 - 0
services/skill-service/alembic/versions/.gitkeep

@@ -0,0 +1 @@
+

+ 163 - 0
services/skill-service/alembic/versions/20260425_0001_init_skill_models.py

@@ -0,0 +1,163 @@
+"""init skill models
+
+Revision ID: 20260425_0001
+Revises:
+Create Date: 2026-04-25 15:10:00
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260425_0001"
+down_revision: str | None = None
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "skill_definition",
+        sa.Column("code", sa.String(length=64), nullable=False),
+        sa.Column("name", sa.String(length=128), nullable=False),
+        sa.Column("skill_type", sa.String(length=32), nullable=False),
+        sa.Column("description", sa.Text(), nullable=True),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("owner_user_id", sa.String(length=36), nullable=True),
+        sa.Column("metadata_json", sa.JSON(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("tenant_id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_skill_definition_code", "skill_definition", ["code"], unique=False)
+    op.create_index("ix_skill_definition_skill_type", "skill_definition", ["skill_type"], unique=False)
+    op.create_index("ix_skill_definition_status", "skill_definition", ["status"], unique=False)
+    op.create_index("ix_skill_definition_tenant_id", "skill_definition", ["tenant_id"], unique=False)
+
+    op.create_table(
+        "skill_version",
+        sa.Column("skill_id", sa.String(length=36), nullable=False),
+        sa.Column("version_no", sa.Integer(), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("runtime_type", sa.String(length=32), nullable=False),
+        sa.Column("entrypoint", sa.String(length=128), nullable=True),
+        sa.Column("parameter_schema_json", sa.JSON(), nullable=False),
+        sa.Column("output_schema_json", sa.JSON(), nullable=False),
+        sa.Column("implementation_json", sa.JSON(), nullable=False),
+        sa.Column("published_time", sa.DateTime(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("tenant_id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_skill_version_skill_id", "skill_version", ["skill_id"], unique=False)
+    op.create_index("ix_skill_version_status", "skill_version", ["status"], unique=False)
+    op.create_index("ix_skill_version_tenant_id", "skill_version", ["tenant_id"], unique=False)
+
+    op.create_table(
+        "skill_installation",
+        sa.Column("skill_id", sa.String(length=36), nullable=False),
+        sa.Column("skill_version_id", sa.String(length=36), nullable=False),
+        sa.Column("install_scope", sa.String(length=32), nullable=False),
+        sa.Column("scope_id", sa.String(length=64), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("config_json", sa.JSON(), nullable=False),
+        sa.Column("installed_by", sa.String(length=36), nullable=True),
+        sa.Column("installed_time", sa.DateTime(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("tenant_id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_skill_installation_skill_id", "skill_installation", ["skill_id"], unique=False)
+    op.create_index(
+        "ix_skill_installation_skill_version_id",
+        "skill_installation",
+        ["skill_version_id"],
+        unique=False,
+    )
+    op.create_index(
+        "ix_skill_installation_install_scope",
+        "skill_installation",
+        ["install_scope"],
+        unique=False,
+    )
+    op.create_index("ix_skill_installation_scope_id", "skill_installation", ["scope_id"], unique=False)
+    op.create_index("ix_skill_installation_status", "skill_installation", ["status"], unique=False)
+    op.create_index("ix_skill_installation_tenant_id", "skill_installation", ["tenant_id"], unique=False)
+
+    op.create_table(
+        "skill_run",
+        sa.Column("skill_id", sa.String(length=36), nullable=False),
+        sa.Column("skill_version_id", sa.String(length=36), nullable=False),
+        sa.Column("installation_id", sa.String(length=36), nullable=True),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("input_json", sa.JSON(), nullable=False),
+        sa.Column("output_json", sa.JSON(), nullable=True),
+        sa.Column("output_text", sa.Text(), nullable=True),
+        sa.Column("worker_key", sa.String(length=128), nullable=True),
+        sa.Column("started_time", sa.DateTime(), nullable=True),
+        sa.Column("finished_time", sa.DateTime(), nullable=True),
+        sa.Column("error_code", sa.String(length=64), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("tenant_id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_skill_run_skill_id", "skill_run", ["skill_id"], unique=False)
+    op.create_index("ix_skill_run_skill_version_id", "skill_run", ["skill_version_id"], unique=False)
+    op.create_index("ix_skill_run_installation_id", "skill_run", ["installation_id"], unique=False)
+    op.create_index("ix_skill_run_status", "skill_run", ["status"], unique=False)
+    op.create_index("ix_skill_run_tenant_id", "skill_run", ["tenant_id"], unique=False)
+
+
+def downgrade() -> None:
+    op.drop_index("ix_skill_run_tenant_id", table_name="skill_run")
+    op.drop_index("ix_skill_run_status", table_name="skill_run")
+    op.drop_index("ix_skill_run_installation_id", table_name="skill_run")
+    op.drop_index("ix_skill_run_skill_version_id", table_name="skill_run")
+    op.drop_index("ix_skill_run_skill_id", table_name="skill_run")
+    op.drop_table("skill_run")
+
+    op.drop_index("ix_skill_installation_tenant_id", table_name="skill_installation")
+    op.drop_index("ix_skill_installation_status", table_name="skill_installation")
+    op.drop_index("ix_skill_installation_scope_id", table_name="skill_installation")
+    op.drop_index("ix_skill_installation_install_scope", table_name="skill_installation")
+    op.drop_index("ix_skill_installation_skill_version_id", table_name="skill_installation")
+    op.drop_index("ix_skill_installation_skill_id", table_name="skill_installation")
+    op.drop_table("skill_installation")
+
+    op.drop_index("ix_skill_version_tenant_id", table_name="skill_version")
+    op.drop_index("ix_skill_version_status", table_name="skill_version")
+    op.drop_index("ix_skill_version_skill_id", table_name="skill_version")
+    op.drop_table("skill_version")
+
+    op.drop_index("ix_skill_definition_tenant_id", table_name="skill_definition")
+    op.drop_index("ix_skill_definition_status", table_name="skill_definition")
+    op.drop_index("ix_skill_definition_skill_type", table_name="skill_definition")
+    op.drop_index("ix_skill_definition_code", table_name="skill_definition")
+    op.drop_table("skill_definition")

+ 1 - 0
services/skill-service/app/__init__.py

@@ -0,0 +1 @@
+

+ 1 - 0
services/skill-service/app/api/__init__.py

@@ -0,0 +1 @@
+

+ 158 - 0
services/skill-service/app/api/routes.py

@@ -0,0 +1,158 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from core_domain import ServiceHealth
+
+from app.application.services import SkillApplicationService
+from app.db.session import get_db
+from app.domain.repositories import (
+    SkillDefinitionRepository,
+    SkillInstallationRepository,
+    SkillRunRepository,
+    SkillVersionRepository,
+)
+from app.schemas.skill import (
+    SkillCreateRequest,
+    SkillInstallRequest,
+    SkillInstallationResponse,
+    SkillInstallationStatusUpdateRequest,
+    SkillResponse,
+    SkillRunCreateRequest,
+    SkillRunExecuteRequest,
+    SkillRunResponse,
+    SkillStatusUpdateRequest,
+    SkillVersionCreateRequest,
+    SkillVersionResponse,
+)
+
+router = APIRouter()
+
+
+def get_skill_application_service(db: Session = Depends(get_db)) -> SkillApplicationService:
+    return SkillApplicationService(
+        skill_repository=SkillDefinitionRepository(db),
+        skill_version_repository=SkillVersionRepository(db),
+        installation_repository=SkillInstallationRepository(db),
+        skill_run_repository=SkillRunRepository(db),
+    )
+
+
+@router.get("/health", response_model=ServiceHealth)
+def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
+    db.execute(text("SELECT 1"))
+    return ServiceHealth(service="skill-service", status="ok", database="ok")
+
+
+@router.post("", response_model=SkillResponse)
+def create_skill(
+    payload: SkillCreateRequest,
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> SkillResponse:
+    return SkillResponse.from_entity(service.create_skill(payload))
+
+
+@router.get("", response_model=list[SkillResponse])
+def list_skills(
+    tenant_id: str = Query(...),
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> list[SkillResponse]:
+    return [SkillResponse.from_entity(item) for item in service.list_skills(tenant_id=tenant_id)]
+
+
+@router.patch("/{skill_id}/status", response_model=SkillResponse)
+def update_skill_status(
+    skill_id: str,
+    payload: SkillStatusUpdateRequest,
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> SkillResponse:
+    entity = service.update_skill_status(skill_id=skill_id, payload=payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"skill not found: {skill_id}")
+    return SkillResponse.from_entity(entity)
+
+
+@router.post("/versions", response_model=SkillVersionResponse)
+def create_skill_version(
+    payload: SkillVersionCreateRequest,
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> SkillVersionResponse:
+    try:
+        return SkillVersionResponse.from_entity(service.create_skill_version(payload))
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
+@router.get("/versions", response_model=list[SkillVersionResponse])
+def list_skill_versions(
+    tenant_id: str = Query(...),
+    skill_id: str = Query(...),
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> list[SkillVersionResponse]:
+    return [
+        SkillVersionResponse.from_entity(item)
+        for item in service.list_skill_versions(tenant_id=tenant_id, skill_id=skill_id)
+    ]
+
+
+@router.post("/installations", response_model=SkillInstallationResponse)
+def install_skill(
+    payload: SkillInstallRequest,
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> SkillInstallationResponse:
+    try:
+        return SkillInstallationResponse.from_entity(service.install_skill(payload))
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
+@router.get("/installations", response_model=list[SkillInstallationResponse])
+def list_installations(
+    tenant_id: str = Query(...),
+    install_scope: str | None = Query(default=None),
+    scope_id: str | None = Query(default=None),
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> list[SkillInstallationResponse]:
+    return [
+        SkillInstallationResponse.from_entity(item)
+        for item in service.list_installations(
+            tenant_id=tenant_id,
+            install_scope=install_scope,
+            scope_id=scope_id,
+        )
+    ]
+
+
+@router.patch("/installations/{installation_id}/status", response_model=SkillInstallationResponse)
+def update_installation_status(
+    installation_id: str,
+    payload: SkillInstallationStatusUpdateRequest,
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> SkillInstallationResponse:
+    entity = service.update_installation_status(installation_id=installation_id, payload=payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"skill installation not found: {installation_id}")
+    return SkillInstallationResponse.from_entity(entity)
+
+
+@router.post("/runs", response_model=SkillRunResponse)
+def create_skill_run(
+    payload: SkillRunCreateRequest,
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> SkillRunResponse:
+    try:
+        return SkillRunResponse.from_entity(service.create_skill_run(payload))
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
+@router.post("/runs/{skill_run_id}/execute", response_model=SkillRunResponse)
+def execute_skill_run(
+    skill_run_id: str,
+    payload: SkillRunExecuteRequest,
+    service: SkillApplicationService = Depends(get_skill_application_service),
+) -> SkillRunResponse:
+    entity = service.execute_skill_run(skill_run_id=skill_run_id, payload=payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"skill_run not found: {skill_run_id}")
+    return SkillRunResponse.from_entity(entity)

+ 1 - 0
services/skill-service/app/application/__init__.py

@@ -0,0 +1 @@
+

+ 225 - 0
services/skill-service/app/application/services.py

@@ -0,0 +1,225 @@
+from string import Template
+
+from core_shared import JSONValue
+
+from app.db.models import SkillDefinition, SkillInstallation, SkillRun, SkillVersion
+from app.domain.repositories import (
+    SkillDefinitionRepository,
+    SkillInstallationRepository,
+    SkillRunRepository,
+    SkillVersionRepository,
+)
+from app.schemas.skill import (
+    SkillCreateRequest,
+    SkillInstallRequest,
+    SkillInstallationStatusUpdateRequest,
+    SkillRunCreateRequest,
+    SkillRunExecuteRequest,
+    SkillStatusUpdateRequest,
+    SkillVersionCreateRequest,
+)
+
+
+class SkillApplicationService:
+    def __init__(
+        self,
+        *,
+        skill_repository: SkillDefinitionRepository,
+        skill_version_repository: SkillVersionRepository,
+        installation_repository: SkillInstallationRepository,
+        skill_run_repository: SkillRunRepository,
+    ) -> None:
+        self.skill_repository = skill_repository
+        self.skill_version_repository = skill_version_repository
+        self.installation_repository = installation_repository
+        self.skill_run_repository = skill_run_repository
+
+    def create_skill(self, payload: SkillCreateRequest) -> SkillDefinition:
+        return self.skill_repository.create(
+            tenant_id=payload.tenant_id,
+            code=payload.code,
+            name=payload.name,
+            skill_type=payload.skill_type,
+            description=payload.description,
+            owner_user_id=payload.owner_user_id,
+            metadata_json=payload.metadata_json,
+        )
+
+    def list_skills(self, *, tenant_id: str) -> list[SkillDefinition]:
+        return self.skill_repository.list_by_tenant(tenant_id=tenant_id)
+
+    def update_skill_status(
+        self,
+        *,
+        skill_id: str,
+        payload: SkillStatusUpdateRequest,
+    ) -> SkillDefinition | None:
+        return self.skill_repository.update_status(
+            tenant_id=payload.tenant_id,
+            skill_id=skill_id,
+            status=payload.status,
+        )
+
+    def create_skill_version(self, payload: SkillVersionCreateRequest) -> SkillVersion:
+        skill = self.skill_repository.get_by_id(tenant_id=payload.tenant_id, skill_id=payload.skill_id)
+        if skill is None:
+            raise ValueError(f"skill not found: {payload.skill_id}")
+        return self.skill_version_repository.create(
+            tenant_id=payload.tenant_id,
+            skill_id=payload.skill_id,
+            status=payload.status,
+            runtime_type=payload.runtime_type,
+            entrypoint=payload.entrypoint,
+            parameter_schema_json=payload.parameter_schema_json,
+            output_schema_json=payload.output_schema_json,
+            implementation_json=payload.implementation_json,
+        )
+
+    def list_skill_versions(self, *, tenant_id: str, skill_id: str) -> list[SkillVersion]:
+        return self.skill_version_repository.list_by_skill(tenant_id=tenant_id, skill_id=skill_id)
+
+    def install_skill(self, payload: SkillInstallRequest) -> SkillInstallation:
+        version = self._resolve_skill_version(
+            tenant_id=payload.tenant_id,
+            skill_id=payload.skill_id,
+            skill_version_id=payload.skill_version_id,
+        )
+        if version is None:
+            raise ValueError("published skill version not found")
+        return self.installation_repository.create(
+            tenant_id=payload.tenant_id,
+            skill_id=payload.skill_id,
+            skill_version_id=version.id,
+            install_scope=payload.install_scope,
+            scope_id=payload.scope_id,
+            config_json=payload.config_json,
+            installed_by=payload.installed_by,
+        )
+
+    def list_installations(
+        self,
+        *,
+        tenant_id: str,
+        install_scope: str | None = None,
+        scope_id: str | None = None,
+    ) -> list[SkillInstallation]:
+        return self.installation_repository.list_by_scope(
+            tenant_id=tenant_id,
+            install_scope=install_scope,
+            scope_id=scope_id,
+        )
+
+    def update_installation_status(
+        self,
+        *,
+        installation_id: str,
+        payload: SkillInstallationStatusUpdateRequest,
+    ) -> SkillInstallation | None:
+        return self.installation_repository.update_status(
+            tenant_id=payload.tenant_id,
+            installation_id=installation_id,
+            status=payload.status,
+        )
+
+    def create_skill_run(self, payload: SkillRunCreateRequest) -> SkillRun:
+        version = self._resolve_skill_version(
+            tenant_id=payload.tenant_id,
+            skill_id=payload.skill_id,
+            skill_version_id=payload.skill_version_id,
+        )
+        if version is None:
+            raise ValueError("published skill version not found")
+        return self.skill_run_repository.create(
+            tenant_id=payload.tenant_id,
+            skill_id=payload.skill_id,
+            skill_version_id=version.id,
+            installation_id=payload.installation_id,
+            input_json=payload.input_json,
+        )
+
+    def execute_skill_run(
+        self,
+        *,
+        skill_run_id: str,
+        payload: SkillRunExecuteRequest,
+    ) -> SkillRun | None:
+        run = self.skill_run_repository.get_by_id(
+            tenant_id=payload.tenant_id,
+            skill_run_id=skill_run_id,
+        )
+        if run is None:
+            return None
+        version = self.skill_version_repository.get_by_id(
+            tenant_id=payload.tenant_id,
+            skill_version_id=run.skill_version_id,
+        )
+        if version is None:
+            return self.skill_run_repository.update_status(
+                skill_run_id=run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="skill_version_missing",
+                error_message=f"skill version not found: {run.skill_version_id}",
+            )
+        self.skill_run_repository.update_status(
+            skill_run_id=run.id,
+            status="running",
+            worker_key=payload.worker_key,
+        )
+        try:
+            output_text, output_json = self._execute_version(version=version, input_json=run.input_json)
+        except ValueError as exc:
+            return self.skill_run_repository.update_status(
+                skill_run_id=run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="skill_execution_error",
+                error_message=str(exc),
+            )
+        return self.skill_run_repository.update_status(
+            skill_run_id=run.id,
+            status="completed",
+            worker_key=payload.worker_key,
+            output_text=output_text,
+            output_json=output_json,
+        )
+
+    def _resolve_skill_version(
+        self,
+        *,
+        tenant_id: str,
+        skill_id: str,
+        skill_version_id: str | None,
+    ) -> SkillVersion | None:
+        if skill_version_id is not None:
+            return self.skill_version_repository.get_by_id(
+                tenant_id=tenant_id,
+                skill_version_id=skill_version_id,
+            )
+        return self.skill_version_repository.get_latest_published(
+            tenant_id=tenant_id,
+            skill_id=skill_id,
+        )
+
+    def _execute_version(
+        self,
+        *,
+        version: SkillVersion,
+        input_json: dict[str, JSONValue],
+    ) -> tuple[str | None, dict[str, JSONValue]]:
+        if version.runtime_type != "template":
+            raise ValueError(f"unsupported skill runtime_type: {version.runtime_type}")
+        template_value = version.implementation_json.get("template")
+        if not isinstance(template_value, str):
+            raise ValueError("template skill requires implementation_json.template")
+        substitutions = {
+            key: str(value)
+            for key, value in input_json.items()
+            if isinstance(value, (str, int, float, bool))
+        }
+        output_text = Template(template_value).safe_substitute(substitutions)
+        return output_text, {
+            "runtime_type": version.runtime_type,
+            "entrypoint": version.entrypoint,
+            "result": output_text,
+        }

+ 1 - 0
services/skill-service/app/bootstrap/__init__.py

@@ -0,0 +1 @@
+

+ 14 - 0
services/skill-service/app/bootstrap/app.py

@@ -0,0 +1,14 @@
+from fastapi import FastAPI
+
+from app.api.routes import router
+from app.bootstrap.settings import SkillServiceSettings
+from app.db.session import build_session_factory
+
+
+def create_app() -> FastAPI:
+    settings = SkillServiceSettings()
+    app = FastAPI(title="agent-platform skill-service", version="0.1.0")
+    app.state.settings = settings
+    app.state.session_factory = build_session_factory(settings)
+    app.include_router(router, prefix="/skills", tags=["skills"])
+    return app

+ 7 - 0
services/skill-service/app/bootstrap/settings.py

@@ -0,0 +1,7 @@
+from core_shared import ServiceSettings
+
+
+class SkillServiceSettings(ServiceSettings):
+    service_name: str = "skill-service"
+    service_port: int = 8010
+    database_url: str = "sqlite:///./skill_service.db"

+ 1 - 0
services/skill-service/app/db/__init__.py

@@ -0,0 +1 @@
+

+ 8 - 0
services/skill-service/app/db/models/__init__.py

@@ -0,0 +1,8 @@
+from core_db import Base
+
+from .skill_definition import SkillDefinition
+from .skill_installation import SkillInstallation
+from .skill_run import SkillRun
+from .skill_version import SkillVersion
+
+__all__ = ["Base", "SkillDefinition", "SkillInstallation", "SkillRun", "SkillVersion"]

+ 18 - 0
services/skill-service/app/db/models/skill_definition.py

@@ -0,0 +1,18 @@
+from sqlalchemy import String, Text
+from sqlalchemy.dialects.sqlite import JSON
+from sqlalchemy.orm import Mapped, mapped_column
+
+from core_db import AuditMixin, Base, TenantMixin, VersionMixin
+from core_shared import JSONValue
+
+
+class SkillDefinition(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "skill_definition"
+
+    code: Mapped[str] = mapped_column(String(64), index=True)
+    name: Mapped[str] = mapped_column(String(128))
+    skill_type: Mapped[str] = mapped_column(String(32), default="template", index=True)
+    description: Mapped[str | None] = mapped_column(Text, nullable=True)
+    status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
+    owner_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    metadata_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)

+ 21 - 0
services/skill-service/app/db/models/skill_installation.py

@@ -0,0 +1,21 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, String
+from sqlalchemy.dialects.sqlite import JSON
+from sqlalchemy.orm import Mapped, mapped_column
+
+from core_db import AuditMixin, Base, TenantMixin, VersionMixin
+from core_shared import JSONValue
+
+
+class SkillInstallation(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "skill_installation"
+
+    skill_id: Mapped[str] = mapped_column(String(36), index=True)
+    skill_version_id: Mapped[str] = mapped_column(String(36), index=True)
+    install_scope: Mapped[str] = mapped_column(String(32), default="tenant", index=True)
+    scope_id: Mapped[str] = mapped_column(String(64), index=True)
+    status: Mapped[str] = mapped_column(String(32), default="installed", index=True)
+    config_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    installed_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    installed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 25 - 0
services/skill-service/app/db/models/skill_run.py

@@ -0,0 +1,25 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, String, Text
+from sqlalchemy.dialects.sqlite import JSON
+from sqlalchemy.orm import Mapped, mapped_column
+
+from core_db import AuditMixin, Base, TenantMixin, VersionMixin
+from core_shared import JSONValue
+
+
+class SkillRun(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "skill_run"
+
+    skill_id: Mapped[str] = mapped_column(String(36), index=True)
+    skill_version_id: Mapped[str] = mapped_column(String(36), index=True)
+    installation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    status: Mapped[str] = mapped_column(String(32), default="queued", index=True)
+    input_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    output_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
+    output_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+    worker_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    finished_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 22 - 0
services/skill-service/app/db/models/skill_version.py

@@ -0,0 +1,22 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String
+from sqlalchemy.dialects.sqlite import JSON
+from sqlalchemy.orm import Mapped, mapped_column
+
+from core_db import AuditMixin, Base, TenantMixin, VersionMixin
+from core_shared import JSONValue
+
+
+class SkillVersion(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "skill_version"
+
+    skill_id: Mapped[str] = mapped_column(String(36), index=True)
+    version_no: Mapped[int] = mapped_column(Integer)
+    status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
+    runtime_type: Mapped[str] = mapped_column(String(32), default="template")
+    entrypoint: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    parameter_schema_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    output_schema_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    implementation_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    published_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 28 - 0
services/skill-service/app/db/session.py

@@ -0,0 +1,28 @@
+from collections.abc import Generator
+
+from fastapi import Request
+from sqlalchemy.orm import Session, sessionmaker
+
+from core_db import DatabaseSettings, create_engine_from_settings, create_session_factory
+
+from app.bootstrap.settings import SkillServiceSettings
+
+
+def build_session_factory(settings: SkillServiceSettings | None = None) -> sessionmaker[Session]:
+    resolved_settings = settings or SkillServiceSettings()
+    engine = create_engine_from_settings(
+        DatabaseSettings(
+            database_url=resolved_settings.database_url,
+            echo_sql=resolved_settings.echo_sql,
+        )
+    )
+    return create_session_factory(engine)
+
+
+def get_db(request: Request) -> Generator[Session, None, None]:
+    session_factory: sessionmaker[Session] = request.app.state.session_factory
+    session = session_factory()
+    try:
+        yield session
+    finally:
+        session.close()

+ 1 - 0
services/skill-service/app/domain/__init__.py

@@ -0,0 +1 @@
+

+ 270 - 0
services/skill-service/app/domain/repositories.py

@@ -0,0 +1,270 @@
+from datetime import datetime
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from core_domain import SkillInstallStatus, SkillRunStatus, SkillStatus, SkillVersionStatus
+from core_shared import JSONValue
+
+from app.db.models import SkillDefinition, SkillInstallation, SkillRun, SkillVersion
+
+
+class SkillDefinitionRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        code: str,
+        name: str,
+        skill_type: str,
+        description: str | None,
+        owner_user_id: str | None,
+        metadata_json: dict[str, JSONValue] | None,
+    ) -> SkillDefinition:
+        entity = SkillDefinition(
+            tenant_id=tenant_id,
+            code=code,
+            name=name,
+            skill_type=skill_type,
+            description=description,
+            owner_user_id=owner_user_id,
+            metadata_json=metadata_json,
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_tenant(self, *, tenant_id: str) -> list[SkillDefinition]:
+        stmt = (
+            select(SkillDefinition)
+            .where(SkillDefinition.tenant_id == tenant_id)
+            .order_by(SkillDefinition.created_time.desc())
+        )
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, tenant_id: str, skill_id: str) -> SkillDefinition | None:
+        stmt = (
+            select(SkillDefinition)
+            .where(SkillDefinition.tenant_id == tenant_id)
+            .where(SkillDefinition.id == skill_id)
+        )
+        return self.db.scalar(stmt)
+
+    def update_status(
+        self,
+        *,
+        tenant_id: str,
+        skill_id: str,
+        status: SkillStatus,
+    ) -> SkillDefinition | None:
+        entity = self.get_by_id(tenant_id=tenant_id, skill_id=skill_id)
+        if entity is None:
+            return None
+        entity.status = status
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+
+class SkillVersionRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        skill_id: str,
+        status: SkillVersionStatus,
+        runtime_type: str,
+        entrypoint: str | None,
+        parameter_schema_json: dict[str, JSONValue],
+        output_schema_json: dict[str, JSONValue],
+        implementation_json: dict[str, JSONValue],
+    ) -> SkillVersion:
+        entity = SkillVersion(
+            tenant_id=tenant_id,
+            skill_id=skill_id,
+            version_no=self._next_version_no(skill_id),
+            status=status,
+            runtime_type=runtime_type,
+            entrypoint=entrypoint,
+            parameter_schema_json=parameter_schema_json,
+            output_schema_json=output_schema_json,
+            implementation_json=implementation_json,
+            published_time=datetime.utcnow() if status == "published" else None,
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_skill(self, *, tenant_id: str, skill_id: str) -> list[SkillVersion]:
+        stmt = (
+            select(SkillVersion)
+            .where(SkillVersion.tenant_id == tenant_id)
+            .where(SkillVersion.skill_id == skill_id)
+            .order_by(SkillVersion.version_no.desc())
+        )
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, tenant_id: str, skill_version_id: str) -> SkillVersion | None:
+        stmt = (
+            select(SkillVersion)
+            .where(SkillVersion.tenant_id == tenant_id)
+            .where(SkillVersion.id == skill_version_id)
+        )
+        return self.db.scalar(stmt)
+
+    def get_latest_published(self, *, tenant_id: str, skill_id: str) -> SkillVersion | None:
+        stmt = (
+            select(SkillVersion)
+            .where(SkillVersion.tenant_id == tenant_id)
+            .where(SkillVersion.skill_id == skill_id)
+            .where(SkillVersion.status == "published")
+            .order_by(SkillVersion.version_no.desc())
+            .limit(1)
+        )
+        return self.db.scalar(stmt)
+
+    def _next_version_no(self, skill_id: str) -> int:
+        stmt = select(func.max(SkillVersion.version_no)).where(SkillVersion.skill_id == skill_id)
+        return (self.db.scalar(stmt) or 0) + 1
+
+
+class SkillInstallationRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        skill_id: str,
+        skill_version_id: str,
+        install_scope: str,
+        scope_id: str,
+        config_json: dict[str, JSONValue],
+        installed_by: str | None,
+    ) -> SkillInstallation:
+        entity = SkillInstallation(
+            tenant_id=tenant_id,
+            skill_id=skill_id,
+            skill_version_id=skill_version_id,
+            install_scope=install_scope,
+            scope_id=scope_id,
+            config_json=config_json,
+            status="installed",
+            installed_by=installed_by,
+            installed_time=datetime.utcnow(),
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_scope(
+        self,
+        *,
+        tenant_id: str,
+        install_scope: str | None = None,
+        scope_id: str | None = None,
+    ) -> list[SkillInstallation]:
+        stmt = select(SkillInstallation).where(SkillInstallation.tenant_id == tenant_id)
+        if install_scope is not None:
+            stmt = stmt.where(SkillInstallation.install_scope == install_scope)
+        if scope_id is not None:
+            stmt = stmt.where(SkillInstallation.scope_id == scope_id)
+        stmt = stmt.order_by(SkillInstallation.created_time.desc())
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, tenant_id: str, installation_id: str) -> SkillInstallation | None:
+        stmt = (
+            select(SkillInstallation)
+            .where(SkillInstallation.tenant_id == tenant_id)
+            .where(SkillInstallation.id == installation_id)
+        )
+        return self.db.scalar(stmt)
+
+    def update_status(
+        self,
+        *,
+        tenant_id: str,
+        installation_id: str,
+        status: SkillInstallStatus,
+    ) -> SkillInstallation | None:
+        entity = self.get_by_id(tenant_id=tenant_id, installation_id=installation_id)
+        if entity is None:
+            return None
+        entity.status = status
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+
+class SkillRunRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        skill_id: str,
+        skill_version_id: str,
+        installation_id: str | None,
+        input_json: dict[str, JSONValue],
+    ) -> SkillRun:
+        entity = SkillRun(
+            tenant_id=tenant_id,
+            skill_id=skill_id,
+            skill_version_id=skill_version_id,
+            installation_id=installation_id,
+            input_json=input_json,
+            status="queued",
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def get_by_id(self, *, tenant_id: str, skill_run_id: str) -> SkillRun | None:
+        stmt = (
+            select(SkillRun)
+            .where(SkillRun.tenant_id == tenant_id)
+            .where(SkillRun.id == skill_run_id)
+        )
+        return self.db.scalar(stmt)
+
+    def update_status(
+        self,
+        *,
+        skill_run_id: str,
+        status: SkillRunStatus,
+        worker_key: str | None = None,
+        output_json: dict[str, JSONValue] | None = None,
+        output_text: str | None = None,
+        error_code: str | None = None,
+        error_message: str | None = None,
+    ) -> SkillRun | None:
+        entity = self.db.get(SkillRun, skill_run_id)
+        if entity is None:
+            return None
+        now = datetime.utcnow()
+        entity.status = status
+        entity.worker_key = worker_key
+        entity.output_json = output_json
+        entity.output_text = output_text
+        entity.error_code = error_code
+        entity.error_message = error_message
+        if status == "running" and entity.started_time is None:
+            entity.started_time = now
+        if status in {"completed", "failed", "cancelled"}:
+            entity.finished_time = now
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 3 - 0
services/skill-service/app/main.py

@@ -0,0 +1,3 @@
+from app.bootstrap.app import create_app
+
+app = create_app()

+ 1 - 0
services/skill-service/app/schemas/__init__.py

@@ -0,0 +1 @@
+

+ 95 - 0
services/skill-service/app/schemas/skill.py

@@ -0,0 +1,95 @@
+from typing import TYPE_CHECKING
+
+from pydantic import BaseModel, Field
+
+from core_domain import (
+    SkillDefinitionContract,
+    SkillInstallationContract,
+    SkillInstallStatus,
+    SkillRunContract,
+    SkillStatus,
+    SkillVersionContract,
+    SkillVersionStatus,
+)
+from core_shared import JSONValue
+
+if TYPE_CHECKING:
+    from app.db.models import SkillDefinition, SkillInstallation, SkillRun, SkillVersion
+
+
+class SkillCreateRequest(BaseModel):
+    tenant_id: str
+    code: str
+    name: str
+    skill_type: str = "template"
+    description: str | None = None
+    owner_user_id: str | None = None
+    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class SkillStatusUpdateRequest(BaseModel):
+    tenant_id: str
+    status: SkillStatus
+
+
+class SkillResponse(SkillDefinitionContract):
+    @classmethod
+    def from_entity(cls, entity: "SkillDefinition") -> "SkillResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+class SkillVersionCreateRequest(BaseModel):
+    tenant_id: str
+    skill_id: str
+    status: SkillVersionStatus = "draft"
+    runtime_type: str = "template"
+    entrypoint: str | None = None
+    parameter_schema_json: dict[str, JSONValue] = Field(default_factory=dict)
+    output_schema_json: dict[str, JSONValue] = Field(default_factory=dict)
+    implementation_json: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class SkillVersionResponse(SkillVersionContract):
+    @classmethod
+    def from_entity(cls, entity: "SkillVersion") -> "SkillVersionResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+class SkillInstallRequest(BaseModel):
+    tenant_id: str
+    skill_id: str
+    skill_version_id: str | None = None
+    install_scope: str = "tenant"
+    scope_id: str
+    config_json: dict[str, JSONValue] = Field(default_factory=dict)
+    installed_by: str | None = None
+
+
+class SkillInstallationStatusUpdateRequest(BaseModel):
+    tenant_id: str
+    status: SkillInstallStatus
+
+
+class SkillInstallationResponse(SkillInstallationContract):
+    @classmethod
+    def from_entity(cls, entity: "SkillInstallation") -> "SkillInstallationResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+class SkillRunCreateRequest(BaseModel):
+    tenant_id: str
+    skill_id: str
+    skill_version_id: str | None = None
+    installation_id: str | None = None
+    input_json: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class SkillRunExecuteRequest(BaseModel):
+    tenant_id: str
+    worker_key: str | None = None
+
+
+class SkillRunResponse(SkillRunContract):
+    @classmethod
+    def from_entity(cls, entity: "SkillRun") -> "SkillRunResponse":
+        return cls.model_validate(entity, from_attributes=True)

+ 25 - 0
services/skill-service/pyproject.toml

@@ -0,0 +1,25 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "skill-service"
+version = "0.1.0"
+description = "Skill registry, installation, and execution service for agent platform."
+requires-python = ">=3.11"
+dependencies = [
+  "alembic>=1.13,<2.0",
+  "fastapi>=0.111,<1.0",
+  "uvicorn[standard]>=0.30,<1.0",
+  "pydantic>=2.7,<3.0",
+  "sqlalchemy>=2.0,<3.0",
+  "core-db",
+  "core-domain",
+  "core-shared",
+]
+
+[tool.setuptools]
+package-dir = {"" = "."}
+
+[tool.setuptools.packages.find]
+where = ["."]