ソースを参照

feat: persist agent tool invocations

Jax Docker 1 ヶ月 前
コミット
24aa4cabfa

+ 11 - 0
README.md

@@ -267,6 +267,17 @@ Invoke-RestMethod -Method Post `
   -Body '{"tenant_id":"t1","agent_id":"agent-id","session_id":"session-id","input_text":"Summarize this lead."}'
 ```
 
+List tool invocation records for an agent run:
+
+```powershell
+Invoke-RestMethod `
+  -Uri "http://127.0.0.1:8007/agents/runs/agent-run-id/tool-invocations?tenant_id=t1"
+```
+
+Agent execution now persists tool invocation audit records with selected,
+running, skipped, completed, or failed status, including input/output payloads
+and `started_time` / `finished_time`.
+
 Through `api-gateway`, use `/gateway/agents/**`.
 
 ## Memory Service APIs

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

@@ -10,6 +10,10 @@ from .agent_contracts import (
     AgentVersionContract,
     AgentVersionStatus,
 )
+from .agent_tool_invocation_contracts import (
+    AgentToolInvocationContract,
+    AgentToolInvocationStatus,
+)
 from .auth_contracts import (
     PermissionCheckContract,
     PermissionCheckResultContract,
@@ -108,6 +112,8 @@ __all__ = [
     "AgentRunStatus",
     "AgentSkillRefContract",
     "AgentStatus",
+    "AgentToolInvocationContract",
+    "AgentToolInvocationStatus",
     "AgentToolRefContract",
     "AgentVersionContract",
     "AgentVersionStatus",

+ 28 - 0
libs/core-domain/src/core_domain/agent_tool_invocation_contracts.py

@@ -0,0 +1,28 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from core_shared import JSONValue
+
+
+AgentToolInvocationStatus = Literal["selected", "skipped", "running", "completed", "failed"]
+
+
+class AgentToolInvocationContract(BaseModel):
+    id: str
+    tenant_id: str
+    agent_run_id: str
+    agent_id: str
+    agent_version_id: str
+    tool_code: str | None = None
+    tool_binding_id: str | None = None
+    status: AgentToolInvocationStatus
+    reason: str | None = None
+    input_json: dict[str, JSONValue] = Field(default_factory=dict)
+    output_text: str | None = None
+    output_json: dict[str, JSONValue] | None = None
+    error_message: str | None = None
+    started_time: datetime | None = None
+    finished_time: datetime | None = None
+    created_time: datetime

+ 97 - 0
services/agent-service/alembic/versions/20260426_0003_add_agent_tool_invocations.py

@@ -0,0 +1,97 @@
+"""add agent tool invocations
+
+Revision ID: 20260426_0003
+Revises: 20260425_0002
+Create Date: 2026-04-26 10:30:00
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260426_0003"
+down_revision: str | None = "20260425_0002"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "agent_tool_invocation",
+        sa.Column("agent_run_id", sa.String(length=36), nullable=False),
+        sa.Column("agent_id", sa.String(length=36), nullable=False),
+        sa.Column("agent_version_id", sa.String(length=36), nullable=False),
+        sa.Column("tool_code", sa.String(length=128), nullable=True),
+        sa.Column("tool_binding_id", sa.String(length=36), nullable=True),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("reason", sa.String(length=128), nullable=True),
+        sa.Column("input_json", sa.JSON(), nullable=False),
+        sa.Column("output_text", sa.Text(), nullable=True),
+        sa.Column("output_json", sa.JSON(), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("started_time", sa.DateTime(), nullable=True),
+        sa.Column("finished_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_agent_tool_invocation_agent_id",
+        "agent_tool_invocation",
+        ["agent_id"],
+    )
+    op.create_index(
+        "ix_agent_tool_invocation_agent_run_id",
+        "agent_tool_invocation",
+        ["agent_run_id"],
+    )
+    op.create_index(
+        "ix_agent_tool_invocation_agent_version_id",
+        "agent_tool_invocation",
+        ["agent_version_id"],
+    )
+    op.create_index(
+        "ix_agent_tool_invocation_status",
+        "agent_tool_invocation",
+        ["status"],
+    )
+    op.create_index(
+        "ix_agent_tool_invocation_tenant_id",
+        "agent_tool_invocation",
+        ["tenant_id"],
+    )
+    op.create_index(
+        "ix_agent_tool_invocation_tool_binding_id",
+        "agent_tool_invocation",
+        ["tool_binding_id"],
+    )
+    op.create_index(
+        "ix_agent_tool_invocation_tool_code",
+        "agent_tool_invocation",
+        ["tool_code"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("ix_agent_tool_invocation_tool_code", table_name="agent_tool_invocation")
+    op.drop_index(
+        "ix_agent_tool_invocation_tool_binding_id",
+        table_name="agent_tool_invocation",
+    )
+    op.drop_index("ix_agent_tool_invocation_tenant_id", table_name="agent_tool_invocation")
+    op.drop_index("ix_agent_tool_invocation_status", table_name="agent_tool_invocation")
+    op.drop_index(
+        "ix_agent_tool_invocation_agent_version_id",
+        table_name="agent_tool_invocation",
+    )
+    op.drop_index("ix_agent_tool_invocation_agent_run_id", table_name="agent_tool_invocation")
+    op.drop_index("ix_agent_tool_invocation_agent_id", table_name="agent_tool_invocation")
+    op.drop_table("agent_tool_invocation")

+ 19 - 0
services/agent-service/app/api/routes.py

@@ -16,6 +16,7 @@ from app.schemas.agent import (
     AgentRunResponse,
     AgentRunStatusUpdateRequest,
     AgentStatusUpdateRequest,
+    AgentToolInvocationResponse,
     AgentWorkerExecuteNextRequest,
     AgentWorkerExecuteNextResponse,
     AgentVersionCreateRequest,
@@ -124,6 +125,24 @@ def list_agent_runs(
     ]
 
 
+@router.get(
+    "/runs/{agent_run_id}/tool-invocations",
+    response_model=list[AgentToolInvocationResponse],
+)
+def list_agent_tool_invocations(
+    agent_run_id: str,
+    tenant_id: str = Query(...),
+    service: AgentApplicationService = Depends(get_agent_application_service),
+) -> list[AgentToolInvocationResponse]:
+    return [
+        AgentToolInvocationResponse.from_entity(item)
+        for item in service.list_agent_tool_invocations(
+            tenant_id=tenant_id,
+            agent_run_id=agent_run_id,
+        )
+    ]
+
+
 @router.post("/runs/{agent_run_id}/status", response_model=AgentRunResponse)
 def update_agent_run_status(
     agent_run_id: str,

+ 67 - 1
services/agent-service/app/application/services.py

@@ -17,10 +17,11 @@ from core_domain import (
 from core_shared import JSONValue
 
 from app.bootstrap.settings import AgentServiceSettings
-from app.db.models import AgentDefinition, AgentRun, AgentVersion
+from app.db.models import AgentDefinition, AgentRun, AgentToolInvocation, AgentVersion
 from app.domain.repositories import (
     AgentDefinitionRepository,
     AgentRunRepository,
+    AgentToolInvocationRepository,
     AgentVersionRepository,
 )
 from app.infrastructure.model_gateway_client import ModelGatewayClient, ModelGatewayClientError
@@ -44,6 +45,7 @@ class AgentApplicationService:
         agent_repository: AgentDefinitionRepository,
         agent_version_repository: AgentVersionRepository,
         agent_run_repository: AgentRunRepository,
+        agent_tool_invocation_repository: AgentToolInvocationRepository,
         model_gateway_client: ModelGatewayClient | None = None,
         memory_client: MemoryClient | None = None,
         tool_client: ToolServiceClient | None = None,
@@ -53,6 +55,7 @@ class AgentApplicationService:
         self.agent_repository = agent_repository
         self.agent_version_repository = agent_version_repository
         self.agent_run_repository = agent_run_repository
+        self.agent_tool_invocation_repository = agent_tool_invocation_repository
         self.model_gateway_client = model_gateway_client
         self.memory_client = memory_client
         self.tool_client = tool_client
@@ -146,6 +149,17 @@ class AgentApplicationService:
             session_id=session_id,
         )
 
+    def list_agent_tool_invocations(
+        self,
+        *,
+        tenant_id: str,
+        agent_run_id: str,
+    ) -> list[AgentToolInvocation]:
+        return self.agent_tool_invocation_repository.list_by_run(
+            tenant_id=tenant_id,
+            agent_run_id=agent_run_id,
+        )
+
     def update_agent_run_status(
         self,
         *,
@@ -252,6 +266,7 @@ class AgentApplicationService:
 
         tool_invocations = self._invoke_selected_tools(
             agent_run=agent_run,
+            agent_version=agent_version,
             selected_tools=selected_tools,
         )
         skill_invocations = self._invoke_selected_skills(
@@ -494,11 +509,27 @@ class AgentApplicationService:
         self,
         *,
         agent_run: AgentRun,
+        agent_version: AgentVersion,
         selected_tools: list[AgentToolRefContract],
     ) -> list[dict[str, JSONValue]]:
         invocations: list[dict[str, JSONValue]] = []
         for ref in selected_tools:
+            invocation = self.agent_tool_invocation_repository.create(
+                tenant_id=agent_run.tenant_id,
+                agent_run_id=agent_run.id,
+                agent_id=agent_run.agent_id,
+                agent_version_id=agent_version.id,
+                tool_code=ref.tool_code,
+                tool_binding_id=ref.tool_binding_id,
+                status="selected",
+                input_json=agent_run.input_json or {},
+            )
             if ref.tool_binding_id is None:
+                self.agent_tool_invocation_repository.update_status(
+                    invocation_id=invocation.id,
+                    status="skipped",
+                    reason="tool_binding_id_missing",
+                )
                 invocations.append(
                     {
                         "status": "skipped",
@@ -508,6 +539,12 @@ class AgentApplicationService:
                 )
                 continue
             if self.tool_client is None:
+                self.agent_tool_invocation_repository.update_status(
+                    invocation_id=invocation.id,
+                    status="failed",
+                    reason="tool_client_missing",
+                    error_message="tool client is not configured",
+                )
                 invocations.append(
                     {
                         "status": "failed",
@@ -517,11 +554,21 @@ class AgentApplicationService:
                 )
                 continue
             try:
+                self.agent_tool_invocation_repository.update_status(
+                    invocation_id=invocation.id,
+                    status="running",
+                )
                 detail = self.tool_client.get_tool_binding_detail(
                     tenant_id=agent_run.tenant_id,
                     binding_id=ref.tool_binding_id,
                 )
                 if not detail.binding.enabled:
+                    self.agent_tool_invocation_repository.update_status(
+                        invocation_id=invocation.id,
+                        status="failed",
+                        reason="tool_binding_disabled",
+                        error_message="tool binding is disabled",
+                    )
                     invocations.append(
                         {
                             "status": "failed",
@@ -531,6 +578,12 @@ class AgentApplicationService:
                     )
                     continue
                 if detail.tool_definition.tool_type != "http":
+                    self.agent_tool_invocation_repository.update_status(
+                        invocation_id=invocation.id,
+                        status="skipped",
+                        reason="unsupported_tool_type",
+                        error_message=detail.tool_definition.tool_type,
+                    )
                     invocations.append(
                         {
                             "status": "skipped",
@@ -546,6 +599,12 @@ class AgentApplicationService:
                     config_json=ref.config_json,
                 )
             except ToolServiceClientError as exc:
+                self.agent_tool_invocation_repository.update_status(
+                    invocation_id=invocation.id,
+                    status="failed",
+                    reason="tool_service_error",
+                    error_message=str(exc),
+                )
                 invocations.append(
                     {
                         "status": "failed",
@@ -555,6 +614,12 @@ class AgentApplicationService:
                 )
                 continue
 
+            self.agent_tool_invocation_repository.update_status(
+                invocation_id=invocation.id,
+                status="completed",
+                output_text=output_text,
+                output_json=output_json,
+            )
             invocations.append(
                 {
                     "status": "completed",
@@ -939,6 +1004,7 @@ def build_agent_application_service(
         agent_repository=AgentDefinitionRepository(db),
         agent_version_repository=AgentVersionRepository(db),
         agent_run_repository=AgentRunRepository(db),
+        agent_tool_invocation_repository=AgentToolInvocationRepository(db),
         model_gateway_client=ModelGatewayClient(
             base_url=settings.model_gateway_service_url,
             timeout_seconds=settings.model_gateway_timeout_seconds,

+ 8 - 1
services/agent-service/app/db/models/__init__.py

@@ -2,6 +2,13 @@ from core_db import Base
 
 from .agent_definition import AgentDefinition
 from .agent_run import AgentRun
+from .agent_tool_invocation import AgentToolInvocation
 from .agent_version import AgentVersion
 
-__all__ = ["AgentDefinition", "AgentRun", "AgentVersion", "Base"]
+__all__ = [
+    "AgentDefinition",
+    "AgentRun",
+    "AgentToolInvocation",
+    "AgentVersion",
+    "Base",
+]

+ 26 - 0
services/agent-service/app/db/models/agent_tool_invocation.py

@@ -0,0 +1,26 @@
+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 AgentToolInvocation(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "agent_tool_invocation"
+
+    agent_run_id: Mapped[str] = mapped_column(String(36), index=True)
+    agent_id: Mapped[str] = mapped_column(String(36), index=True)
+    agent_version_id: Mapped[str] = mapped_column(String(36), index=True)
+    tool_code: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
+    tool_binding_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    status: Mapped[str] = mapped_column(String(32), default="selected", index=True)
+    reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    input_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    output_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+    output_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    finished_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 85 - 2
services/agent-service/app/domain/repositories.py

@@ -3,10 +3,15 @@ from datetime import datetime
 from sqlalchemy import func, select
 from sqlalchemy.orm import Session
 
-from core_domain import AgentRunStatus, AgentStatus, AgentVersionStatus
+from core_domain import (
+    AgentRunStatus,
+    AgentStatus,
+    AgentToolInvocationStatus,
+    AgentVersionStatus,
+)
 from core_shared import JSONValue
 
-from app.db.models import AgentDefinition, AgentRun, AgentVersion
+from app.db.models import AgentDefinition, AgentRun, AgentToolInvocation, AgentVersion
 
 
 class AgentDefinitionRepository:
@@ -275,3 +280,81 @@ class AgentRunRepository:
         self.db.commit()
         self.db.refresh(entity)
         return entity
+
+
+class AgentToolInvocationRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        agent_run_id: str,
+        agent_id: str,
+        agent_version_id: str,
+        tool_code: str | None,
+        tool_binding_id: str | None,
+        status: AgentToolInvocationStatus,
+        reason: str | None = None,
+        input_json: dict[str, JSONValue] | None = None,
+    ) -> AgentToolInvocation:
+        entity = AgentToolInvocation(
+            tenant_id=tenant_id,
+            agent_run_id=agent_run_id,
+            agent_id=agent_id,
+            agent_version_id=agent_version_id,
+            tool_code=tool_code,
+            tool_binding_id=tool_binding_id,
+            status=status,
+            reason=reason,
+            input_json=input_json or {},
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_run(
+        self,
+        *,
+        tenant_id: str,
+        agent_run_id: str,
+    ) -> list[AgentToolInvocation]:
+        stmt = (
+            select(AgentToolInvocation)
+            .where(AgentToolInvocation.tenant_id == tenant_id)
+            .where(AgentToolInvocation.agent_run_id == agent_run_id)
+            .order_by(AgentToolInvocation.created_time.asc())
+        )
+        return list(self.db.scalars(stmt))
+
+    def update_status(
+        self,
+        *,
+        invocation_id: str,
+        status: AgentToolInvocationStatus,
+        reason: str | None = None,
+        output_text: str | None = None,
+        output_json: dict[str, JSONValue] | None = None,
+        error_message: str | None = None,
+    ) -> AgentToolInvocation | None:
+        entity = self.db.get(AgentToolInvocation, invocation_id)
+        if entity is None:
+            return None
+
+        now = datetime.utcnow()
+        entity.status = status
+        entity.reason = reason
+        entity.output_text = output_text
+        entity.output_json = output_json
+        entity.error_message = error_message
+        if status == "running" and entity.started_time is None:
+            entity.started_time = now
+        if status in {"completed", "failed", "skipped"}:
+            if entity.started_time is None:
+                entity.started_time = now
+            entity.finished_time = now
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 8 - 1
services/agent-service/app/schemas/agent.py

@@ -11,6 +11,7 @@ from core_domain import (
     AgentRunStatus,
     AgentSkillRefContract,
     AgentStatus,
+    AgentToolInvocationContract,
     AgentToolRefContract,
     AgentVersionContract,
     AgentVersionStatus,
@@ -18,7 +19,7 @@ from core_domain import (
 from core_shared import JSONValue
 
 if TYPE_CHECKING:
-    from app.db.models import AgentDefinition, AgentRun, AgentVersion
+    from app.db.models import AgentDefinition, AgentRun, AgentToolInvocation, AgentVersion
 
 
 class AgentCreateRequest(BaseModel):
@@ -103,6 +104,12 @@ class AgentRunResponse(AgentRunContract):
         return cls.model_validate(entity, from_attributes=True)
 
 
+class AgentToolInvocationResponse(AgentToolInvocationContract):
+    @classmethod
+    def from_entity(cls, entity: "AgentToolInvocation") -> "AgentToolInvocationResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
 class AgentRunExecuteResponse(BaseModel):
     run: AgentRunResponse
     model: str | None = None