Browse Source

feat: add human task service

Jax Docker 1 month ago
parent
commit
ae2e98e737
30 changed files with 709 additions and 0 deletions
  1. 35 0
      README.md
  2. 27 0
      deployments/docker/docker-compose.yml
  3. 8 0
      libs/core-domain/src/core_domain/__init__.py
  4. 39 0
      libs/core-domain/src/core_domain/human_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/human-service/alembic.ini
  10. 38 0
      services/human-service/alembic/env.py
  11. 1 0
      services/human-service/alembic/versions/.gitkeep
  12. 72 0
      services/human-service/alembic/versions/20260425_0001_init_human_models.py
  13. 1 0
      services/human-service/app/__init__.py
  14. 1 0
      services/human-service/app/api/__init__.py
  15. 80 0
      services/human-service/app/api/routes.py
  16. 1 0
      services/human-service/app/application/__init__.py
  17. 72 0
      services/human-service/app/application/services.py
  18. 1 0
      services/human-service/app/bootstrap/__init__.py
  19. 14 0
      services/human-service/app/bootstrap/app.py
  20. 7 0
      services/human-service/app/bootstrap/settings.py
  21. 1 0
      services/human-service/app/db/__init__.py
  22. 5 0
      services/human-service/app/db/models/__init__.py
  23. 29 0
      services/human-service/app/db/models/human_task.py
  24. 28 0
      services/human-service/app/db/session.py
  25. 1 0
      services/human-service/app/domain/__init__.py
  26. 111 0
      services/human-service/app/domain/repositories.py
  27. 3 0
      services/human-service/app/main.py
  28. 1 0
      services/human-service/app/schemas/__init__.py
  29. 42 0
      services/human-service/app/schemas/human.py
  30. 25 0
      services/human-service/pyproject.toml

+ 35 - 0
README.md

@@ -20,6 +20,7 @@
 - `memory-service`
 - `team-service`
 - `skill-service`
+- `human-service`
 - `tool-service`
 
 每个服务都提供了最小 `FastAPI` 启动入口和健康检查接口,数据库相关服务也已经带上了 `SQLAlchemy` 模型骨架与 Alembic 目录。
@@ -53,6 +54,7 @@ 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\human-service
 pip install -e .\services\tool-service
 ```
 
@@ -197,6 +199,7 @@ services/
   workflow-service/
   runtime-service/
   skill-service/
+  human-service/
   tool-service/
 libs/
   core-domain/
@@ -360,6 +363,36 @@ Invoke-RestMethod -Method Post `
 
 Through `api-gateway`, use `/gateway/skills/**`.
 
+## Human Service APIs
+
+`human-service` stores human-in-the-loop tasks for approval, input collection,
+takeover, pause, and resume flows.
+
+Create an approval task:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8011/human/tasks `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","task_type":"approval","title":"Approve refund","run_id":"run-id","node_run_id":"node-run-id","assigned_to":"ops-1","request_payload_json":{"amount":99}}'
+```
+
+Claim and complete a task:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8011/human/tasks/human-task-id/claim `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","claimed_by":"ops-1"}'
+
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8011/human/tasks/human-task-id/complete `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","status":"approved","response_payload_json":{"approved":true}}'
+```
+
+Through `api-gateway`, use `/gateway/human/**`.
+
 Execute an agent run without calling an external model:
 
 ```powershell
@@ -663,6 +696,7 @@ $env:AGENT_PLATFORM_SMOKE_RUNTIME_URL="http://127.0.0.1:8000/gateway/runtime"
 - `/gateway/memories/**` -> `memory-service /memories/**`
 - `/gateway/teams/**` -> `team-service /teams/**`
 - `/gateway/skills/**` -> `skill-service /skills/**`
+- `/gateway/human/**` -> `human-service /human/**`
 - `/gateway/tools/**` -> `tool-service /tools/**`
 - `/gateway/models/**` -> `model-gateway-service /models/**`
 - `/gateway/code/**` -> `code-runner-service /code/**`
@@ -898,6 +932,7 @@ Important notes:
 - `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`
+- `human-service` stores human approval, input, pause/resume, and takeover task records 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

@@ -223,6 +223,26 @@ services:
       timeout: 5s
       retries: 5
 
+  human-service:
+    build:
+      context: ../..
+      dockerfile: deployments/docker/python-service.Dockerfile
+      args:
+        SERVICE_PATH: services/human-service
+    container_name: agent-platform-human-service
+    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8011"]
+    environment:
+      AGENT_PLATFORM_DATABASE_URL: sqlite:////data/human_service.db
+    ports:
+      - "8011:8011"
+    volumes:
+      - human_service_data:/data
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8011/human/health').read()"]
+      interval: 15s
+      timeout: 5s
+      retries: 5
+
   runtime-service:
     build:
       context: ../..
@@ -241,6 +261,7 @@ services:
       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_HUMAN_SERVICE_URL: http://human-service:8011
     ports:
       - "8003:8003"
     volumes:
@@ -262,6 +283,8 @@ services:
         condition: service_started
       skill-service:
         condition: service_started
+      human-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
@@ -315,6 +338,7 @@ services:
       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_HUMAN_SERVICE_URL: http://human-service:8011
       AGENT_PLATFORM_AUTH_REQUIRED: ${AGENT_PLATFORM_AUTH_REQUIRED:-false}
     ports:
       - "8000:8000"
@@ -341,6 +365,8 @@ services:
         condition: service_started
       skill-service:
         condition: service_started
+      human-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
@@ -353,6 +379,7 @@ volumes:
   memory_service_data:
   team_service_data:
   skill_service_data:
+  human_service_data:
   workflow_service_data:
   session_service_data:
   runtime_service_data:

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

@@ -17,6 +17,11 @@ from .execution_contracts import (
     NodeExecutionResultContract,
     RunExecutionRequestContract,
 )
+from .human_contracts import (
+    HumanTaskContract,
+    HumanTaskStatus,
+    HumanTaskType,
+)
 from .model_contracts import (
     ChatCompletionRequestContract,
     ChatCompletionResponseContract,
@@ -86,6 +91,9 @@ __all__ = [
     "ChatCompletionRequestContract",
     "ChatCompletionResponseContract",
     "ChatMessageContract",
+    "HumanTaskContract",
+    "HumanTaskStatus",
+    "HumanTaskType",
     "InitialNodeContract",
     "MemoryCreateContract",
     "MemoryItemContract",

+ 39 - 0
libs/core-domain/src/core_domain/human_contracts.py

@@ -0,0 +1,39 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from core_shared import JSONValue
+
+
+HumanTaskType = Literal["approval", "input", "takeover", "pause", "resume"]
+HumanTaskStatus = Literal[
+    "pending",
+    "claimed",
+    "approved",
+    "rejected",
+    "completed",
+    "cancelled",
+]
+
+
+class HumanTaskContract(BaseModel):
+    id: str
+    tenant_id: str
+    task_type: HumanTaskType
+    status: HumanTaskStatus
+    title: str
+    description: str | None = None
+    source_type: str | None = None
+    source_id: str | None = None
+    run_id: str | None = None
+    node_run_id: str | None = None
+    requested_by: str | None = None
+    assigned_to: str | None = None
+    claimed_by: str | None = None
+    request_payload_json: dict[str, JSONValue] = Field(default_factory=dict)
+    response_payload_json: dict[str, JSONValue] | None = None
+    due_time: datetime | None = None
+    claimed_time: datetime | None = None
+    completed_time: datetime | None = None
+    created_time: datetime

+ 1 - 0
pyproject.toml

@@ -8,6 +8,7 @@ members = [
   "services/api-gateway",
   "services/agent-service",
   "services/code-runner-service",
+  "services/human-service",
   "services/memory-service",
   "services/model-gateway-service",
   "services/session-service",

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

@@ -175,6 +175,12 @@ def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName,
             path_prefix="/skills",
             health_path="/skills/health",
         ),
+        "human-service": ProxyTarget(
+            service_name="human-service",
+            base_url=settings.human_service_url,
+            path_prefix="/human",
+            health_path="/human/health",
+        ),
     }
 
 
@@ -341,6 +347,27 @@ async def proxy_skill_service(
     )
 
 
+@router.api_route(
+    "/gateway/human",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+@router.api_route(
+    "/gateway/human/{path:path}",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+async def proxy_human_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)["human-service"],
+        path=path,
+    )
+
+
 @router.api_route(
     "/gateway/tools",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"],

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

@@ -15,6 +15,7 @@ class ApiGatewaySettings(ServiceSettings):
     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"
+    human_service_url: str = "http://127.0.0.1:8011"
     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

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

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

@@ -0,0 +1,36 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = sqlite:///./human_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/human-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/human-service/alembic/versions/.gitkeep

@@ -0,0 +1 @@
+

+ 72 - 0
services/human-service/alembic/versions/20260425_0001_init_human_models.py

@@ -0,0 +1,72 @@
+"""init human models
+
+Revision ID: 20260425_0001
+Revises:
+Create Date: 2026-04-25 15:50: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(
+        "human_task",
+        sa.Column("task_type", sa.String(length=32), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("title", sa.String(length=128), nullable=False),
+        sa.Column("description", sa.Text(), nullable=True),
+        sa.Column("source_type", sa.String(length=64), nullable=True),
+        sa.Column("source_id", sa.String(length=64), nullable=True),
+        sa.Column("run_id", sa.String(length=36), nullable=True),
+        sa.Column("node_run_id", sa.String(length=36), nullable=True),
+        sa.Column("requested_by", sa.String(length=36), nullable=True),
+        sa.Column("assigned_to", sa.String(length=36), nullable=True),
+        sa.Column("claimed_by", sa.String(length=36), nullable=True),
+        sa.Column("request_payload_json", sa.JSON(), nullable=False),
+        sa.Column("response_payload_json", sa.JSON(), nullable=True),
+        sa.Column("due_time", sa.DateTime(), nullable=True),
+        sa.Column("claimed_time", sa.DateTime(), nullable=True),
+        sa.Column("completed_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_human_task_task_type", "human_task", ["task_type"], unique=False)
+    op.create_index("ix_human_task_status", "human_task", ["status"], unique=False)
+    op.create_index("ix_human_task_source_type", "human_task", ["source_type"], unique=False)
+    op.create_index("ix_human_task_source_id", "human_task", ["source_id"], unique=False)
+    op.create_index("ix_human_task_run_id", "human_task", ["run_id"], unique=False)
+    op.create_index("ix_human_task_node_run_id", "human_task", ["node_run_id"], unique=False)
+    op.create_index("ix_human_task_assigned_to", "human_task", ["assigned_to"], unique=False)
+    op.create_index("ix_human_task_claimed_by", "human_task", ["claimed_by"], unique=False)
+    op.create_index("ix_human_task_due_time", "human_task", ["due_time"], unique=False)
+    op.create_index("ix_human_task_tenant_id", "human_task", ["tenant_id"], unique=False)
+
+
+def downgrade() -> None:
+    op.drop_index("ix_human_task_tenant_id", table_name="human_task")
+    op.drop_index("ix_human_task_due_time", table_name="human_task")
+    op.drop_index("ix_human_task_claimed_by", table_name="human_task")
+    op.drop_index("ix_human_task_assigned_to", table_name="human_task")
+    op.drop_index("ix_human_task_node_run_id", table_name="human_task")
+    op.drop_index("ix_human_task_run_id", table_name="human_task")
+    op.drop_index("ix_human_task_source_id", table_name="human_task")
+    op.drop_index("ix_human_task_source_type", table_name="human_task")
+    op.drop_index("ix_human_task_status", table_name="human_task")
+    op.drop_index("ix_human_task_task_type", table_name="human_task")
+    op.drop_table("human_task")

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

@@ -0,0 +1 @@
+"""Human-in-the-loop service package."""

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

@@ -0,0 +1 @@
+"""API package."""

+ 80 - 0
services/human-service/app/api/routes.py

@@ -0,0 +1,80 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from core_domain import HumanTaskStatus, ServiceHealth
+
+from app.application.services import HumanApplicationService
+from app.db.session import get_db
+from app.domain.repositories import HumanTaskRepository
+from app.schemas.human import (
+    HumanTaskClaimRequest,
+    HumanTaskCompleteRequest,
+    HumanTaskCreateRequest,
+    HumanTaskResponse,
+)
+
+router = APIRouter()
+
+
+def get_human_application_service(db: Session = Depends(get_db)) -> HumanApplicationService:
+    return HumanApplicationService(human_task_repository=HumanTaskRepository(db))
+
+
+@router.get("/health", response_model=ServiceHealth)
+def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
+    db.execute(text("SELECT 1"))
+    return ServiceHealth(service="human-service", status="ok", database="ok")
+
+
+@router.post("/tasks", response_model=HumanTaskResponse)
+def create_human_task(
+    payload: HumanTaskCreateRequest,
+    service: HumanApplicationService = Depends(get_human_application_service),
+) -> HumanTaskResponse:
+    return HumanTaskResponse.from_entity(service.create_task(payload))
+
+
+@router.get("/tasks", response_model=list[HumanTaskResponse])
+def list_human_tasks(
+    tenant_id: str = Query(...),
+    status: HumanTaskStatus | None = Query(default=None),
+    assigned_to: str | None = Query(default=None),
+    run_id: str | None = Query(default=None),
+    limit: int = Query(default=100, ge=1, le=500),
+    service: HumanApplicationService = Depends(get_human_application_service),
+) -> list[HumanTaskResponse]:
+    return [
+        HumanTaskResponse.from_entity(item)
+        for item in service.list_tasks(
+            tenant_id=tenant_id,
+            status=status,
+            assigned_to=assigned_to,
+            run_id=run_id,
+            limit=limit,
+        )
+    ]
+
+
+@router.post("/tasks/{human_task_id}/claim", response_model=HumanTaskResponse)
+def claim_human_task(
+    human_task_id: str,
+    payload: HumanTaskClaimRequest,
+    service: HumanApplicationService = Depends(get_human_application_service),
+) -> HumanTaskResponse:
+    entity = service.claim_task(human_task_id=human_task_id, payload=payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"human task not found: {human_task_id}")
+    return HumanTaskResponse.from_entity(entity)
+
+
+@router.post("/tasks/{human_task_id}/complete", response_model=HumanTaskResponse)
+def complete_human_task(
+    human_task_id: str,
+    payload: HumanTaskCompleteRequest,
+    service: HumanApplicationService = Depends(get_human_application_service),
+) -> HumanTaskResponse:
+    entity = service.complete_task(human_task_id=human_task_id, payload=payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"human task not found: {human_task_id}")
+    return HumanTaskResponse.from_entity(entity)

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

@@ -0,0 +1 @@
+"""Application package."""

+ 72 - 0
services/human-service/app/application/services.py

@@ -0,0 +1,72 @@
+from core_domain import HumanTaskStatus
+
+from app.db.models import HumanTask
+from app.domain.repositories import HumanTaskRepository
+from app.schemas.human import (
+    HumanTaskClaimRequest,
+    HumanTaskCompleteRequest,
+    HumanTaskCreateRequest,
+)
+
+
+class HumanApplicationService:
+    def __init__(self, *, human_task_repository: HumanTaskRepository) -> None:
+        self.human_task_repository = human_task_repository
+
+    def create_task(self, payload: HumanTaskCreateRequest) -> HumanTask:
+        return self.human_task_repository.create(
+            tenant_id=payload.tenant_id,
+            task_type=payload.task_type,
+            title=payload.title,
+            description=payload.description,
+            source_type=payload.source_type,
+            source_id=payload.source_id,
+            run_id=payload.run_id,
+            node_run_id=payload.node_run_id,
+            requested_by=payload.requested_by,
+            assigned_to=payload.assigned_to,
+            request_payload_json=payload.request_payload_json,
+            due_time=payload.due_time,
+        )
+
+    def list_tasks(
+        self,
+        *,
+        tenant_id: str,
+        status: HumanTaskStatus | None = None,
+        assigned_to: str | None = None,
+        run_id: str | None = None,
+        limit: int = 100,
+    ) -> list[HumanTask]:
+        return self.human_task_repository.list_by_scope(
+            tenant_id=tenant_id,
+            status=status,
+            assigned_to=assigned_to,
+            run_id=run_id,
+            limit=limit,
+        )
+
+    def claim_task(
+        self,
+        *,
+        human_task_id: str,
+        payload: HumanTaskClaimRequest,
+    ) -> HumanTask | None:
+        return self.human_task_repository.claim(
+            tenant_id=payload.tenant_id,
+            human_task_id=human_task_id,
+            claimed_by=payload.claimed_by,
+        )
+
+    def complete_task(
+        self,
+        *,
+        human_task_id: str,
+        payload: HumanTaskCompleteRequest,
+    ) -> HumanTask | None:
+        return self.human_task_repository.complete(
+            tenant_id=payload.tenant_id,
+            human_task_id=human_task_id,
+            status=payload.status,
+            response_payload_json=payload.response_payload_json,
+        )

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

@@ -0,0 +1 @@
+"""Bootstrap package."""

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

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

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

@@ -0,0 +1,7 @@
+from core_shared import ServiceSettings
+
+
+class HumanServiceSettings(ServiceSettings):
+    service_name: str = "human-service"
+    service_port: int = 8011
+    database_url: str = "sqlite:///./human_service.db"

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

@@ -0,0 +1 @@
+"""Database package."""

+ 5 - 0
services/human-service/app/db/models/__init__.py

@@ -0,0 +1,5 @@
+from core_db import Base
+
+from .human_task import HumanTask
+
+__all__ = ["Base", "HumanTask"]

+ 29 - 0
services/human-service/app/db/models/human_task.py

@@ -0,0 +1,29 @@
+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 HumanTask(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "human_task"
+
+    task_type: Mapped[str] = mapped_column(String(32), index=True)
+    status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
+    title: Mapped[str] = mapped_column(String(128))
+    description: Mapped[str | None] = mapped_column(Text, nullable=True)
+    source_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+    source_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+    run_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    node_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    requested_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    assigned_to: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    claimed_by: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    request_payload_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    response_payload_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
+    due_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
+    claimed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    completed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 28 - 0
services/human-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 HumanServiceSettings
+
+
+def build_session_factory(settings: HumanServiceSettings | None = None) -> sessionmaker[Session]:
+    resolved_settings = settings or HumanServiceSettings()
+    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/human-service/app/domain/__init__.py

@@ -0,0 +1 @@
+"""Domain package."""

+ 111 - 0
services/human-service/app/domain/repositories.py

@@ -0,0 +1,111 @@
+from datetime import datetime
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from core_domain import HumanTaskStatus, HumanTaskType
+from core_shared import JSONValue
+
+from app.db.models import HumanTask
+
+
+class HumanTaskRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        task_type: HumanTaskType,
+        title: str,
+        description: str | None,
+        source_type: str | None,
+        source_id: str | None,
+        run_id: str | None,
+        node_run_id: str | None,
+        requested_by: str | None,
+        assigned_to: str | None,
+        request_payload_json: dict[str, JSONValue],
+        due_time: datetime | None,
+    ) -> HumanTask:
+        entity = HumanTask(
+            tenant_id=tenant_id,
+            task_type=task_type,
+            title=title,
+            description=description,
+            source_type=source_type,
+            source_id=source_id,
+            run_id=run_id,
+            node_run_id=node_run_id,
+            requested_by=requested_by,
+            assigned_to=assigned_to,
+            request_payload_json=request_payload_json,
+            due_time=due_time,
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_scope(
+        self,
+        *,
+        tenant_id: str,
+        status: HumanTaskStatus | None = None,
+        assigned_to: str | None = None,
+        run_id: str | None = None,
+        limit: int = 100,
+    ) -> list[HumanTask]:
+        stmt = select(HumanTask).where(HumanTask.tenant_id == tenant_id)
+        if status is not None:
+            stmt = stmt.where(HumanTask.status == status)
+        if assigned_to is not None:
+            stmt = stmt.where(HumanTask.assigned_to == assigned_to)
+        if run_id is not None:
+            stmt = stmt.where(HumanTask.run_id == run_id)
+        stmt = stmt.order_by(HumanTask.created_time.desc()).limit(limit)
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, tenant_id: str, human_task_id: str) -> HumanTask | None:
+        stmt = (
+            select(HumanTask)
+            .where(HumanTask.tenant_id == tenant_id)
+            .where(HumanTask.id == human_task_id)
+        )
+        return self.db.scalar(stmt)
+
+    def claim(
+        self,
+        *,
+        tenant_id: str,
+        human_task_id: str,
+        claimed_by: str,
+    ) -> HumanTask | None:
+        entity = self.get_by_id(tenant_id=tenant_id, human_task_id=human_task_id)
+        if entity is None:
+            return None
+        entity.status = "claimed"
+        entity.claimed_by = claimed_by
+        entity.claimed_time = datetime.utcnow()
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def complete(
+        self,
+        *,
+        tenant_id: str,
+        human_task_id: str,
+        status: HumanTaskStatus,
+        response_payload_json: dict[str, JSONValue],
+    ) -> HumanTask | None:
+        entity = self.get_by_id(tenant_id=tenant_id, human_task_id=human_task_id)
+        if entity is None:
+            return None
+        entity.status = status
+        entity.response_payload_json = response_payload_json
+        entity.completed_time = datetime.utcnow()
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

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

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

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

@@ -0,0 +1 @@
+"""Schemas package."""

+ 42 - 0
services/human-service/app/schemas/human.py

@@ -0,0 +1,42 @@
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from pydantic import BaseModel, Field
+
+from core_domain import HumanTaskContract, HumanTaskStatus, HumanTaskType
+from core_shared import JSONValue
+
+if TYPE_CHECKING:
+    from app.db.models import HumanTask
+
+
+class HumanTaskCreateRequest(BaseModel):
+    tenant_id: str
+    task_type: HumanTaskType
+    title: str
+    description: str | None = None
+    source_type: str | None = None
+    source_id: str | None = None
+    run_id: str | None = None
+    node_run_id: str | None = None
+    requested_by: str | None = None
+    assigned_to: str | None = None
+    request_payload_json: dict[str, JSONValue] = Field(default_factory=dict)
+    due_time: datetime | None = None
+
+
+class HumanTaskClaimRequest(BaseModel):
+    tenant_id: str
+    claimed_by: str
+
+
+class HumanTaskCompleteRequest(BaseModel):
+    tenant_id: str
+    status: HumanTaskStatus
+    response_payload_json: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class HumanTaskResponse(HumanTaskContract):
+    @classmethod
+    def from_entity(cls, entity: "HumanTask") -> "HumanTaskResponse":
+        return cls.model_validate(entity, from_attributes=True)

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

@@ -0,0 +1,25 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "human-service"
+version = "0.1.0"
+description = "Human-in-the-loop task 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 = ["."]