Jelajahi Sumber

feat: improve team chat streaming workflow

Jax Docker 1 bulan lalu
induk
melakukan
2ce2642351
29 mengubah file dengan 2276 tambahan dan 460 penghapusan
  1. 0 19
      libs/core-dsl/pyproject.toml
  2. 0 19
      libs/core-dsl/src/core_dsl/__init__.py
  3. 0 53
      libs/core-dsl/src/core_dsl/workflow.py
  4. 35 0
      services/agent-service/app/api/routes.py
  5. 153 0
      services/agent-service/app/application/services.py
  6. 51 0
      services/agent-service/app/infrastructure/model_gateway_client.py
  7. 32 0
      services/api-gateway/app/infrastructure/proxy.py
  8. 27 0
      services/model-gateway-service/app/api/routes.py
  9. 35 0
      services/model-gateway-service/app/application/services.py
  10. 190 0
      services/model-gateway-service/app/infrastructure/provider.py
  11. 58 0
      services/team-service/app/api/routes.py
  12. 193 2
      services/team-service/app/application/services.py
  13. 56 0
      services/team-service/app/infrastructure/agent_client.py
  14. 2 0
      services/team-service/app/schemas/team.py
  15. 13 0
      services/tool-service/app/api/routes.py
  16. 125 1
      services/tool-service/app/application/services.py
  17. 4 0
      services/tool-service/app/schemas/tool.py
  18. 1 1
      web/src/api/client.ts
  19. 24 0
      web/src/api/mock.ts
  20. 90 3
      web/src/api/teams.ts
  21. 8 0
      web/src/api/tools.ts
  22. 2 2
      web/src/components/ui/tabs.tsx
  23. 12 0
      web/src/locales/en.json
  24. 12 0
      web/src/locales/zh.json
  25. 294 87
      web/src/pages/skills/SkillsPage.tsx
  26. 105 72
      web/src/pages/teams/TeamsPage.tsx
  27. 670 197
      web/src/pages/teams/components/TeamRuns.tsx
  28. 2 0
      web/src/pages/tools/ToolsPage.tsx
  29. 82 4
      web/src/pages/tools/components/ToolDetailSheet.tsx

+ 0 - 19
libs/core-dsl/pyproject.toml

@@ -1,19 +0,0 @@
-[build-system]
-requires = ["setuptools>=68"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "core-dsl"
-version = "0.1.0"
-description = "Workflow DSL models for agent platform."
-requires-python = ">=3.11"
-dependencies = [
-  "core-shared",
-  "pydantic>=2.7,<3.0",
-]
-
-[tool.setuptools]
-package-dir = {"" = "src"}
-
-[tool.setuptools.packages.find]
-where = ["src"]

+ 0 - 19
libs/core-dsl/src/core_dsl/__init__.py

@@ -1,19 +0,0 @@
-from .workflow import (
-    EdgeDefinition,
-    NodeDefinition,
-    WorkflowDefinition,
-    get_initial_node_definition,
-    get_node_definition,
-    get_successor_node_definitions,
-    parse_workflow_definition,
-)
-
-__all__ = [
-    "EdgeDefinition",
-    "NodeDefinition",
-    "WorkflowDefinition",
-    "get_initial_node_definition",
-    "get_node_definition",
-    "get_successor_node_definitions",
-    "parse_workflow_definition",
-]

+ 0 - 53
libs/core-dsl/src/core_dsl/workflow.py

@@ -1,53 +0,0 @@
-from core_shared import JSONValue
-from pydantic import BaseModel, Field
-
-
-class NodeDefinition(BaseModel):
-    id: str
-    type: str
-    name: str | None = None
-    config: dict[str, JSONValue] = Field(default_factory=dict)
-
-
-class EdgeDefinition(BaseModel):
-    source: str
-    target: str
-    condition: str | None = None
-
-
-class WorkflowDefinition(BaseModel):
-    code: str
-    name: str = "workflow"
-    nodes: list[NodeDefinition] = Field(default_factory=list)
-    edges: list[EdgeDefinition] = Field(default_factory=list)
-
-
-def parse_workflow_definition(payload: dict[str, JSONValue] | None) -> WorkflowDefinition | None:
-    if payload is None:
-        return None
-    return WorkflowDefinition.model_validate(payload)
-
-
-def get_node_definition(workflow: WorkflowDefinition, node_id: str) -> NodeDefinition | None:
-    for node in workflow.nodes:
-        if node.id == node_id:
-            return node
-    return None
-
-
-def get_initial_node_definition(workflow: WorkflowDefinition) -> NodeDefinition | None:
-    incoming_targets = {edge.target for edge in workflow.edges}
-    for node in workflow.nodes:
-        if node.id not in incoming_targets:
-            return node
-    if workflow.nodes:
-        return workflow.nodes[0]
-    return None
-
-
-def get_successor_node_definitions(
-    workflow: WorkflowDefinition,
-    current_node_id: str) -> list[NodeDefinition]:
-    successor_ids = [edge.target for edge in workflow.edges if edge.source == current_node_id]
-    node_map = {node.id: node for node in workflow.nodes}
-    return [node_map[item] for item in successor_ids if item in node_map]

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

@@ -1,5 +1,8 @@
+import json
+
 from core_domain import ServiceHealth
 from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import StreamingResponse
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
@@ -37,6 +40,10 @@ from app.schemas.agent import (
 router = APIRouter()
 
 
+def json_dump(payload: dict[str, object]) -> str:
+    return json.dumps(payload, ensure_ascii=False, default=str)
+
+
 def get_agent_service_settings() -> AgentServiceSettings:
     return AgentServiceSettings()
 
@@ -266,6 +273,34 @@ def execute_agent_run(
         dry_run=dry_run_value if isinstance(dry_run_value, bool) else False)
 
 
+@router.post("/runs/{agent_run_id}/execute-stream")
+def execute_agent_run_stream(
+    agent_run_id: str,
+    payload: AgentRunExecuteRequest,
+    service: AgentApplicationService = Depends(get_agent_application_service)) -> StreamingResponse:
+    if service.get_agent_run(AgentRunDetailRequest(agent_run_id=agent_run_id)) is None:
+        raise HTTPException(status_code=404, detail=f"agent_run not found: {agent_run_id}")
+
+    def events():
+        for item in service.execute_agent_run_stream(agent_run_id=agent_run_id, payload=payload):
+            event = item.get("event")
+            event_name = event if isinstance(event, str) else "message"
+            data = {key: value for key, value in item.items() if key != "event"}
+            yield f"event: {event_name}\ndata: {json_dump(data)}\n\n"
+
+    return StreamingResponse(
+        events(),
+        media_type="text/event-stream",
+        headers=_sse_headers())
+
+
+def _sse_headers() -> dict[str, str]:
+    return {
+        "Cache-Control": "no-cache",
+        "X-Accel-Buffering": "no",
+    }
+
+
 @router.post("/runs/execute", response_model=AgentRunExecuteResponse)
 def execute_agent_run_post(
     payload: AgentRunExecutePostRequest,

+ 153 - 0
services/agent-service/app/application/services.py

@@ -1,4 +1,5 @@
 import json
+from collections.abc import Iterator
 from datetime import datetime, timedelta
 from typing import cast
 
@@ -364,6 +365,135 @@ class AgentApplicationService:
                 })
         return completed_run
 
+    def execute_agent_run_stream(
+        self,
+        *,
+        agent_run_id: str,
+        payload: AgentRunExecuteRequest) -> Iterator[dict[str, JSONValue]]:
+        agent_run = self.agent_run_repository.get_by_id(
+            agent_run_id=agent_run_id)
+        if agent_run is None:
+            return
+
+        agent_config = self.agent_config_repository.get_by_id(
+            agent_config_id=agent_run.agent_config_id)
+        if agent_config is None:
+            failed_run = self.agent_run_repository.update_status(
+                agent_run_id=agent_run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="agent_config_missing",
+                error_message=f"agent config not found: {agent_run.agent_config_id}")
+            yield {"event": "agent.run.failed", "run": self._agent_run_to_json(failed_run)}
+            return
+
+        running_run = self.agent_run_repository.update_status(
+            agent_run_id=agent_run.id,
+            status="running",
+            worker_key=payload.worker_key)
+        yield {"event": "agent.run.started", "run": self._agent_run_to_json(running_run)}
+
+        if payload.dry_run or self._read_bool(agent_config.model_config_json, "react_enabled", default=False):
+            completed_run = self.execute_agent_run(agent_run_id=agent_run.id, payload=payload)
+            yield {"event": "agent.run.completed", "run": self._agent_run_to_json(completed_run)}
+            return
+
+        memory_results, memory_metadata = self._read_relevant_memories(
+            agent_run=agent_run,
+            agent_config=agent_config)
+        selected_tools = self._select_tool_refs(agent_run=agent_run, agent_config=agent_config)
+        selected_skills = self._select_skill_refs(agent_run=agent_run, agent_config=agent_config)
+        tool_invocations = self._invoke_selected_tools(
+            agent_run=agent_run,
+            agent_config=agent_config,
+            selected_tools=selected_tools)
+        skill_invocations = self._invoke_selected_skills(
+            agent_run=agent_run,
+            selected_skills=selected_skills,
+            worker_key=payload.worker_key)
+        messages = self._build_chat_messages(
+            agent_run=agent_run,
+            agent_config=agent_config,
+            memory_results=memory_results,
+            capability_context=self._format_capability_results(
+                tool_invocations=tool_invocations,
+                skill_invocations=skill_invocations))
+
+        if self.model_gateway_client is None:
+            failed_run = self.agent_run_repository.update_status(
+                agent_run_id=agent_run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="model_gateway_missing",
+                error_message="model gateway client is not configured",
+                output_json={
+                    "tool_invocations": tool_invocations,
+                    "skill_invocations": skill_invocations,
+                    **memory_metadata,
+                })
+            yield {"event": "agent.run.failed", "run": self._agent_run_to_json(failed_run)}
+            return
+
+        output_parts: list[str] = []
+        try:
+            for delta in self.model_gateway_client.stream_chat_completion(
+                ChatCompletionRequestContract(
+                    model=self._read_optional_string(agent_config.model_config_json, "model"),
+                    temperature=self._read_optional_float(
+                        agent_config.model_config_json,
+                        "temperature"),
+                    max_tokens=self._read_optional_int(
+                        agent_config.model_config_json,
+                        "max_tokens"),
+                    messages=messages,
+                    metadata_json={
+                        "agent_id": agent_run.agent_id,
+                        "agent_config_id": agent_config.id,
+                        "agent_run_id": agent_run.id,
+                    })):
+                output_parts.append(delta)
+                yield {"event": "agent.run.delta", "agent_run_id": agent_run.id, "delta": delta}
+        except ModelGatewayClientError as exc:
+            failed_run = self.agent_run_repository.update_status(
+                agent_run_id=agent_run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="model_gateway_error",
+                error_message=str(exc))
+            yield {"event": "agent.run.failed", "run": self._agent_run_to_json(failed_run)}
+            return
+
+        output_text = "".join(output_parts)
+        memory_write_metadata = self._write_interaction_memory(
+            agent_run=agent_run,
+            agent_config=agent_config,
+            output_text=output_text)
+        completed_run = self.agent_run_repository.update_status(
+            agent_run_id=agent_run.id,
+            status="completed",
+            worker_key=payload.worker_key,
+            output_text=output_text,
+            output_json={
+                "dry_run": False,
+                "agent_config_id": agent_config.id,
+                "streamed": True,
+                "tool_invocations": tool_invocations,
+                "skill_invocations": skill_invocations,
+                **memory_metadata,
+                **memory_write_metadata,
+            })
+        if completed_run is not None:
+            self._publish_event(
+                event_type="agent.run.completed",
+                agent_run=completed_run,
+                payload_json={
+                    "agent_run_id": completed_run.id,
+                    "dry_run": False,
+                    "status": completed_run.status,
+                    "streamed": True,
+                })
+        yield {"event": "agent.run.completed", "run": self._agent_run_to_json(completed_run)}
+
     def _publish_event(
         self,
         *,
@@ -389,6 +519,29 @@ class AgentApplicationService:
         except EventServiceClientError:
             return
 
+    def _agent_run_to_json(self, agent_run: AgentRun | None) -> dict[str, JSONValue]:
+        if agent_run is None:
+            return {}
+        return {
+            "id": agent_run.id,
+            "agent_id": agent_run.agent_id,
+            "agent_config_id": agent_run.agent_config_id,
+            "session_id": agent_run.session_id,
+            "input_text": agent_run.input_text,
+            "input_json": agent_run.input_json,
+            "output_text": agent_run.output_text,
+            "output_json": agent_run.output_json,
+            "status": agent_run.status,
+            "worker_key": agent_run.worker_key,
+            "queued_time": agent_run.queued_time,
+            "lease_expire_time": agent_run.lease_expire_time,
+            "started_time": agent_run.started_time,
+            "finished_time": agent_run.finished_time,
+            "error_code": agent_run.error_code,
+            "error_message": agent_run.error_message,
+            "created_time": agent_run.created_time,
+        }
+
     def _execute_react_agent_run(
         self,
         *,

+ 51 - 0
services/agent-service/app/infrastructure/model_gateway_client.py

@@ -1,3 +1,6 @@
+import json
+from collections.abc import Iterator
+
 import httpx
 from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
 
@@ -23,3 +26,51 @@ class ModelGatewayClient:
                 return ChatCompletionResponseContract.model_validate(response.json())
         except httpx.HTTPError as exc:
             raise ModelGatewayClientError(f"model-gateway-service request failed: {exc}") from exc
+
+    def stream_chat_completion(
+        self,
+        payload: ChatCompletionRequestContract) -> Iterator[str]:
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                with client.stream(
+                    "POST",
+                    f"{self.base_url}/models/chat-completions/stream",
+                    json=payload.model_dump(mode="json")) as response:
+                    response.raise_for_status()
+                    for event_name, data in _iter_sse_events(response):
+                        if event_name == "delta":
+                            delta = data.get("delta")
+                            if isinstance(delta, str):
+                                yield delta
+                        elif event_name == "error":
+                            message = data.get("message")
+                            raise ModelGatewayClientError(
+                                str(message) if isinstance(message, str) else "model-gateway stream failed")
+        except httpx.HTTPError as exc:
+            raise ModelGatewayClientError(f"model-gateway-service stream failed: {exc}") from exc
+
+
+def _iter_sse_events(response: httpx.Response) -> Iterator[tuple[str, dict[str, object]]]:
+    event_name = "message"
+    data_lines: list[str] = []
+    for line in response.iter_lines():
+        if line == "":
+            if data_lines:
+                yield event_name, _parse_json("\n".join(data_lines))
+            event_name = "message"
+            data_lines = []
+            continue
+        if line.startswith("event:"):
+            event_name = line.removeprefix("event:").strip()
+        elif line.startswith("data:"):
+            data_lines.append(line.removeprefix("data:").strip())
+    if data_lines:
+        yield event_name, _parse_json("\n".join(data_lines))
+
+
+def _parse_json(value: str) -> dict[str, object]:
+    try:
+        payload = json.loads(value)
+    except json.JSONDecodeError:
+        return {}
+    return payload if isinstance(payload, dict) else {}

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

@@ -5,6 +5,7 @@ import httpx
 from core_shared.observability import PARENT_SPAN_ID_HEADER, SPAN_ID_HEADER, TRACE_ID_HEADER
 from core_shared.security import build_internal_service_headers
 from fastapi import Request, Response
+from fastapi.responses import StreamingResponse
 
 from app.bootstrap.settings import ApiGatewaySettings
 from app.infrastructure.audit import mark_gateway_target
@@ -65,6 +66,21 @@ class ServiceProxy:
         headers.update(build_internal_service_headers(self.settings))
         body = await request.body()
 
+        if _expects_stream(request):
+            client = httpx.AsyncClient(timeout=self.timeout_seconds)
+            request_builder = client.build_request(
+                method=request.method,
+                url=target_url,
+                params=request.query_params,
+                headers=headers,
+                content=body)
+            upstream_response = await client.send(request_builder, stream=True)
+            return StreamingResponse(
+                _stream_upstream_response(client, upstream_response),
+                status_code=upstream_response.status_code,
+                headers=build_response_headers(upstream_response),
+                media_type=upstream_response.headers.get("content-type"))
+
         async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
             upstream_response = await client.request(
                 method=request.method,
@@ -134,3 +150,19 @@ def build_response_headers(response: httpx.Response) -> dict[str, str]:
         for key, value in response.headers.items()
         if key.lower() not in skipped_headers
     }
+
+
+def _expects_stream(request: Request) -> bool:
+    accept_header = request.headers.get("accept", "")
+    return "text/event-stream" in accept_header or request.url.path.endswith("stream")
+
+
+async def _stream_upstream_response(
+    client: httpx.AsyncClient,
+    response: httpx.Response):
+    try:
+        async for chunk in response.aiter_raw():
+            yield chunk
+    finally:
+        await response.aclose()
+        await client.aclose()

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

@@ -1,3 +1,4 @@
+import json
 from datetime import datetime
 from typing import Annotated, TypeVar
 
@@ -7,6 +8,7 @@ from core_domain import (
     ServiceHealth,
 )
 from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
@@ -77,6 +79,10 @@ def ok(data: T) -> ApiResponse[T]:
         serverTime=datetime.utcnow())
 
 
+def json_dump(payload: dict[str, object]) -> str:
+    return json.dumps(payload, ensure_ascii=False, default=str)
+
+
 @router.get("/health", response_model=ServiceHealth)
 def health_check(
     db: DbSession,
@@ -169,6 +175,27 @@ def create_chat_completion(
         raise HTTPException(status_code=502, detail=str(exc)) from exc
 
 
+@router.post("/chat-completions/stream")
+def stream_chat_completion(
+    payload: ChatCompletionRequestContract,
+    service: ModelServiceDep) -> StreamingResponse:
+    def events():
+        try:
+            for delta in service.stream_chat_completion(payload):
+                yield f"event: delta\ndata: {json_dump({'delta': delta})}\n\n"
+            yield "event: done\ndata: {}\n\n"
+        except ModelProviderClientError as exc:
+            yield f"event: error\ndata: {json_dump({'message': str(exc)})}\n\n"
+
+    return StreamingResponse(
+        events(),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "X-Accel-Buffering": "no",
+        })
+
+
 @router.post("/list", response_model=ApiResponse[PageResult[ModelDto]])
 def list_models_contract(
     payload: PageRequest,

+ 35 - 0
services/model-gateway-service/app/application/services.py

@@ -1,4 +1,5 @@
 from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
+from collections.abc import Iterator
 
 from app.bootstrap.settings import ModelGatewayServiceSettings
 from app.db.models import ModelDefinition, ModelProviderDefinition
@@ -209,6 +210,40 @@ class ModelGatewayApplicationService:
             resolved_payload,
             provider_type=self.settings.provider_type)
 
+    def stream_chat_completion(
+        self,
+        payload: ChatCompletionRequestContract) -> Iterator[str]:
+        configured_model = None
+        if payload.model:
+            configured_model = self.model_repository.get_active_for_request(payload.model)
+
+        if configured_model is not None:
+            configured_provider = self._resolve_model_provider(configured_model)
+            resolved_payload = payload.model_copy(
+                update={
+                    "model": configured_model.model_name,
+                    "temperature": payload.temperature
+                    if payload.temperature is not None
+                    else configured_model.default_temperature,
+                    "max_tokens": payload.max_tokens or configured_model.max_output_tokens,
+                }
+            )
+            yield from self.provider_client.stream_chat_completion(
+                resolved_payload,
+                provider_type=configured_provider.provider_type,
+                provider_base_url=configured_provider.provider_base_url,
+                provider_api_key=configured_provider.provider_api_key,
+                timeout_seconds=configured_model.timeout_seconds,
+            )
+            return
+
+        resolved_payload = payload.model_copy(
+            update={"model": payload.model or self.settings.default_model}
+        )
+        yield from self.provider_client.stream_chat_completion(
+            resolved_payload,
+            provider_type=self.settings.provider_type)
+
     def test_model(self, model_id: str, payload: ModelTestRequest) -> ModelTestResponse | None:
         entity = self.model_repository.get_by_id(model_id)
         if entity is None:

+ 190 - 0
services/model-gateway-service/app/infrastructure/provider.py

@@ -1,3 +1,6 @@
+import json
+from collections.abc import Iterator
+
 import httpx
 from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
 from core_shared import JSONValue
@@ -39,6 +42,33 @@ class ModelProviderClient:
             provider_api_key=provider_api_key,
             timeout_seconds=timeout_seconds)
 
+    def stream_chat_completion(
+        self,
+        payload: ChatCompletionRequestContract,
+        *,
+        provider_type: str | None = None,
+        provider_base_url: str | None = None,
+        provider_api_key: str | None = None,
+        timeout_seconds: float = 60.0,
+    ) -> Iterator[str]:
+        if payload.model is None:
+            raise ModelProviderClientError("model is required for chat completion")
+
+        resolved_provider_type = provider_type or self.settings.provider_type
+        if resolved_provider_type == "anthropic":
+            yield from self._stream_anthropic_message(
+                payload,
+                provider_base_url=provider_base_url,
+                provider_api_key=provider_api_key,
+                timeout_seconds=timeout_seconds)
+            return
+
+        yield from self._stream_openai_compatible_chat_completion(
+            payload,
+            provider_base_url=provider_base_url,
+            provider_api_key=provider_api_key,
+            timeout_seconds=timeout_seconds)
+
     def list_models(
         self,
         *,
@@ -179,6 +209,48 @@ class ModelProviderClient:
             usage_json=usage_json,
             raw_response_json=response_json)
 
+    def _stream_openai_compatible_chat_completion(
+        self,
+        payload: ChatCompletionRequestContract,
+        *,
+        provider_base_url: str | None,
+        provider_api_key: str | None,
+        timeout_seconds: float) -> Iterator[str]:
+        request_payload = _build_openai_request_payload(payload)
+        request_payload["stream"] = True
+        request_headers = _build_openai_headers(
+            settings=self.settings,
+            provider_api_key=provider_api_key)
+
+        try:
+            base_url = provider_base_url or self.settings.provider_base_url
+            with httpx.Client(timeout=timeout_seconds) as client:
+                with client.stream(
+                    "POST",
+                    _join_url(base_url, "chat/completions"),
+                    json=request_payload,
+                    headers=request_headers) as response:
+                    response.raise_for_status()
+                    for line in response.iter_lines():
+                        if not line.startswith("data:"):
+                            continue
+                        data = line.removeprefix("data:").strip()
+                        if data == "[DONE]":
+                            break
+                        try:
+                            payload_json = _coerce_json_dict(json.loads(data))
+                        except json.JSONDecodeError:
+                            continue
+                        delta = _extract_openai_stream_delta(payload_json)
+                        if delta:
+                            yield delta
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:1000]
+            raise ModelProviderClientError(
+                f"model provider stream failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise ModelProviderClientError(f"model provider stream failed: {exc}") from exc
+
     def _create_anthropic_message(
         self,
         payload: ChatCompletionRequestContract,
@@ -235,6 +307,66 @@ class ModelProviderClient:
             usage_json=_extract_usage_json(response_json),
             raw_response_json=response_json)
 
+    def _stream_anthropic_message(
+        self,
+        payload: ChatCompletionRequestContract,
+        *,
+        provider_base_url: str | None,
+        provider_api_key: str | None,
+        timeout_seconds: float) -> Iterator[str]:
+        api_key = (
+            provider_api_key
+            if provider_api_key is not None
+            else self.settings.provider_api_key
+        )
+        if not api_key:
+            raise ModelProviderClientError("anthropic api key is required")
+
+        system_prompt, messages = _to_anthropic_messages(payload)
+        request_payload: dict[str, JSONValue] = {
+            "model": payload.model or "",
+            "max_tokens": payload.max_tokens or 1024,
+            "messages": messages,
+            "stream": True,
+        }
+        if system_prompt:
+            request_payload["system"] = system_prompt
+        if payload.temperature is not None:
+            request_payload["temperature"] = payload.temperature
+
+        request_headers = {
+            "content-type": "application/json",
+            "x-api-key": api_key,
+            "anthropic-version": "2023-06-01",
+        }
+
+        try:
+            base_url = provider_base_url or self.settings.provider_base_url
+            with httpx.Client(timeout=timeout_seconds) as client:
+                with client.stream(
+                    "POST",
+                    _join_url(base_url, "v1/messages"),
+                    json=request_payload,
+                    headers=request_headers) as response:
+                    response.raise_for_status()
+                    for line in response.iter_lines():
+                        if not line.startswith("data:"):
+                            continue
+                        data = line.removeprefix("data:").strip()
+                        try:
+                            payload_json = _coerce_json_dict(json.loads(data))
+                        except json.JSONDecodeError:
+                            continue
+                        delta = _extract_anthropic_stream_delta(payload_json)
+                        if delta:
+                            yield delta
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:1000]
+            raise ModelProviderClientError(
+                f"anthropic stream failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise ModelProviderClientError(f"anthropic stream failed: {exc}") from exc
+
 
 def _coerce_json_dict(payload: JSONValue) -> dict[str, JSONValue]:
     if isinstance(payload, dict):
@@ -242,6 +374,38 @@ def _coerce_json_dict(payload: JSONValue) -> dict[str, JSONValue]:
     return {}
 
 
+def _build_openai_request_payload(
+    payload: ChatCompletionRequestContract) -> dict[str, JSONValue]:
+    request_payload: dict[str, JSONValue] = {
+        "model": payload.model or "",
+        "messages": [item.model_dump(mode="json") for item in payload.messages],
+    }
+    if payload.temperature is not None:
+        request_payload["temperature"] = payload.temperature
+    if payload.max_tokens is not None:
+        request_payload["max_tokens"] = payload.max_tokens
+    if payload.tools_json:
+        request_payload["tools"] = payload.tools_json
+    if payload.tool_choice is not None:
+        request_payload["tool_choice"] = payload.tool_choice
+    return request_payload
+
+
+def _build_openai_headers(
+    *,
+    settings: ModelGatewayServiceSettings,
+    provider_api_key: str | None) -> dict[str, str]:
+    request_headers: dict[str, str] = {"content-type": "application/json"}
+    api_key = (
+        provider_api_key
+        if provider_api_key is not None
+        else settings.provider_api_key
+    )
+    if api_key:
+        request_headers["authorization"] = f"Bearer {api_key}"
+    return request_headers
+
+
 def _join_url(base_url: str, path: str) -> str:
     normalized_base = base_url.rstrip("/")
     normalized_path = path.strip("/")
@@ -356,6 +520,32 @@ def _extract_response_content(payload: dict[str, JSONValue]) -> str:
     return ""
 
 
+def _extract_openai_stream_delta(payload: dict[str, JSONValue]) -> str:
+    choices = payload.get("choices")
+    if not isinstance(choices, list) or not choices:
+        return ""
+    first_choice = choices[0]
+    if not isinstance(first_choice, dict):
+        return ""
+    delta = first_choice.get("delta")
+    if isinstance(delta, dict):
+        content = delta.get("content")
+        if isinstance(content, str):
+            return content
+    text = first_choice.get("text")
+    return text if isinstance(text, str) else ""
+
+
+def _extract_anthropic_stream_delta(payload: dict[str, JSONValue]) -> str:
+    if payload.get("type") != "content_block_delta":
+        return ""
+    delta = payload.get("delta")
+    if not isinstance(delta, dict):
+        return ""
+    text = delta.get("text")
+    return text if isinstance(text, str) else ""
+
+
 def _extract_finish_reason(payload: dict[str, JSONValue]) -> str | None:
     choices = payload.get("choices")
     if isinstance(choices, list) and choices:

+ 58 - 0
services/team-service/app/api/routes.py

@@ -1,9 +1,11 @@
+import json
 from datetime import datetime
 from typing import TypeVar
 
 from core_domain import ServiceHealth
 from core_shared import try_build_redis_client
 from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import StreamingResponse
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
@@ -62,6 +64,10 @@ def ok(data: T) -> ApiResponse[T]:
         serverTime=datetime.utcnow())
 
 
+def json_dump(payload: dict[str, object]) -> str:
+    return json.dumps(payload, ensure_ascii=False, default=str)
+
+
 def get_team_settings() -> TeamServiceSettings:
     return TeamServiceSettings()
 
@@ -313,6 +319,30 @@ def execute_team_run_contract(
         dryRun=dry_run if isinstance(dry_run, bool) else payload.dryRun))
 
 
+@router.post("/runs/execute-stream")
+def execute_team_run_stream_contract(
+    payload: TeamRunExecuteRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
+    if service.get_team_run(team_run_id=payload.teamRunId) is None:
+        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+
+    def events():
+        for item in service.execute_team_run_stream(
+            team_run_id=payload.teamRunId,
+            payload=TeamRunExecuteRequest(
+                worker_key=payload.workerKey,
+                dry_run=payload.dryRun)):
+            event = item.get("event")
+            event_name = event if isinstance(event, str) else "message"
+            data = {key: value for key, value in item.items() if key != "event"}
+            yield f"event: {event_name}\ndata: {json_dump(data)}\n\n"
+
+    return StreamingResponse(
+        events(),
+        media_type="text/event-stream",
+        headers=_sse_headers())
+
+
 @router.post("/runs/delete", response_model=ApiResponse[DeleteData])
 def delete_team_run_contract(
     payload: TeamRunDeleteRequestDto,
@@ -349,6 +379,34 @@ def execute_team_run(
         dry_run=dry_run if isinstance(dry_run, bool) else payload.dry_run)
 
 
+@router.post("/runs/{team_run_id}/execute-stream")
+def execute_team_run_stream(
+    team_run_id: str,
+    payload: TeamRunExecuteRequest,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> StreamingResponse:
+    if service.get_team_run(team_run_id=team_run_id) is None:
+        raise HTTPException(status_code=404, detail=f"team_run not found: {team_run_id}")
+
+    def events():
+        for item in service.execute_team_run_stream(team_run_id=team_run_id, payload=payload):
+            event = item.get("event")
+            event_name = event if isinstance(event, str) else "message"
+            data = {key: value for key, value in item.items() if key != "event"}
+            yield f"event: {event_name}\ndata: {json_dump(data)}\n\n"
+
+    return StreamingResponse(
+        events(),
+        media_type="text/event-stream",
+        headers=_sse_headers())
+
+
+def _sse_headers() -> dict[str, str]:
+    return {
+        "Cache-Control": "no-cache",
+        "X-Accel-Buffering": "no",
+    }
+
+
 @router.post("/workers/execute-next", response_model=TeamWorkerExecuteNextResponse)
 def execute_next_worker_task(
     payload: TeamWorkerExecuteNextRequest,

+ 193 - 2
services/team-service/app/application/services.py

@@ -1,4 +1,5 @@
 from dataclasses import dataclass
+from collections.abc import Iterator
 from datetime import datetime, timedelta
 from concurrent.futures import ThreadPoolExecutor, as_completed
 
@@ -190,7 +191,7 @@ class TeamApplicationService:
             event_type="team.run.created",
             team_run=team_run,
             payload_json={"team_run_id": team_run.id, "status": team_run.status})
-        if self.task_queue_publisher is not None:
+        if payload.enqueue and self.task_queue_publisher is not None:
             self.task_queue_publisher.publish_team_run(
                 team_run_id=team_run.id)
         return team_run
@@ -202,7 +203,8 @@ class TeamApplicationService:
                 team_config_id=payload.teamConfigId,
                 session_id=payload.sessionId,
                 input_text=payload.inputText,
-                input_json=payload.inputJson))
+                input_json=payload.inputJson,
+                enqueue=payload.enqueue))
 
     def list_team_runs(
         self,
@@ -365,6 +367,150 @@ class TeamApplicationService:
                 })
         return completed_run
 
+    def execute_team_run_stream(
+        self,
+        *,
+        team_run_id: str,
+        payload: TeamRunExecuteRequest) -> Iterator[dict[str, JSONValue]]:
+        team_run = self.team_run_repository.get_by_id(team_run_id=team_run_id)
+        if team_run is None:
+            return
+
+        team_config = self.team_config_repository.get_by_id(
+            team_config_id=team_run.team_config_id)
+        if team_config is None:
+            failed_run = self.team_run_repository.update_status(
+                team_run_id=team_run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="team_config_missing",
+                error_message=f"team config not found: {team_run.team_config_id}")
+            yield {"event": "team.run.failed", "run": self._team_run_to_json(failed_run)}
+            return
+
+        running_run = self.team_run_repository.update_status(
+            team_run_id=team_run.id,
+            status="running",
+            worker_key=payload.worker_key)
+        yield {"event": "team.run.started", "run": self._team_run_to_json(running_run)}
+
+        members = self._read_team_members(team_config)
+        if not members:
+            failed_run = self.team_run_repository.update_status(
+                team_run_id=team_run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="team_members_missing",
+                error_message="team config has no valid members")
+            yield {"event": "team.run.failed", "run": self._team_run_to_json(failed_run)}
+            return
+
+        if self.agent_client is None:
+            failed_run = self.team_run_repository.update_status(
+                team_run_id=team_run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                error_code="agent_service_missing",
+                error_message="agent service client is not configured")
+            yield {"event": "team.run.failed", "run": self._team_run_to_json(failed_run)}
+            return
+
+        stream_members = self._select_stream_members(team_config=team_config, members=members)
+        member_results: list[TeamMemberRunResult] = []
+        prior_outputs: list[dict[str, JSONValue]] = []
+        for member in stream_members:
+            member_input_json = self._build_member_input_json(
+                team_run=team_run,
+                team_config=team_config,
+                member=member,
+                prior_outputs=prior_outputs)
+            created_run = self.agent_client.create_agent_run(
+                agent_id=member.agent_id,
+                agent_config_id=member.agent_config_id,
+                session_id=team_run.session_id,
+                input_text=self._build_member_input_text(
+                    team_run=team_run,
+                    team_config=team_config,
+                    member=member),
+                input_json=member_input_json)
+            yield {
+                "event": "team.member.started",
+                "member": self._member_to_json(member),
+                "agent_run": created_run.model_dump(mode="json"),
+            }
+
+            final_agent_run = created_run
+            try:
+                for event_name, data in self.agent_client.execute_agent_run_stream(
+                    agent_run_id=created_run.id,
+                    worker_key=payload.worker_key,
+                    dry_run=payload.dry_run):
+                    if event_name == "agent.run.delta":
+                        delta = data.get("delta")
+                        if isinstance(delta, str):
+                            yield {
+                                "event": "team.member.delta",
+                                "member": self._member_to_json(member),
+                                "agent_run_id": created_run.id,
+                                "delta": delta,
+                            }
+                    elif event_name in {"agent.run.completed", "agent.run.failed"}:
+                        run_payload = data.get("run")
+                        if isinstance(run_payload, dict):
+                            final_agent_run = AgentRunContract.model_validate(run_payload)
+            except AgentServiceClientError as exc:
+                failed_agent_run = created_run.model_copy(
+                    update={
+                        "status": "failed",
+                        "error_code": "agent_service_error",
+                        "error_message": str(exc),
+                    })
+                final_agent_run = failed_agent_run
+
+            result = TeamMemberRunResult(member=member, run=final_agent_run)
+            member_results.append(result)
+            prior_outputs.append(self._compact_prior_output(result))
+            yield {
+                "event": "team.member.completed",
+                "member": self._member_to_json(member),
+                "agent_run": final_agent_run.model_dump(mode="json"),
+            }
+
+        failed_results = [item for item in member_results if item.run.status != "completed"]
+        output_text = self._build_team_output_text(
+            team_config=team_config,
+            member_results=member_results)
+        output_json: dict[str, JSONValue] = {
+            "dry_run": payload.dry_run,
+            "coordination_mode": team_config.coordination_mode,
+            "team_config_id": team_config.id,
+            "member_run_count": len(member_results),
+            "member_results": [
+                self._member_result_to_json(item) for item in member_results
+            ],
+            "streamed": True,
+            "response_mode": self._read_response_mode(team_config),
+        }
+        if failed_results:
+            failed_run = self.team_run_repository.update_status(
+                team_run_id=team_run.id,
+                status="failed",
+                worker_key=payload.worker_key,
+                output_text=output_text,
+                output_json=output_json,
+                error_code="member_run_failed",
+                error_message=f"{len(failed_results)} member run(s) failed")
+            yield {"event": "team.run.failed", "run": self._team_run_to_json(failed_run)}
+            return
+
+        completed_run = self.team_run_repository.update_status(
+            team_run_id=team_run.id,
+            status="completed",
+            worker_key=payload.worker_key,
+            output_text=output_text,
+            output_json=output_json)
+        yield {"event": "team.run.completed", "run": self._team_run_to_json(completed_run)}
+
     def execute_next_claimed_team_run(
         self,
         *,
@@ -547,6 +693,25 @@ class TeamApplicationService:
         }
         return sorted(members, key=lambda item: role_priority.get(item.role, 10))
 
+    def _select_stream_members(
+        self,
+        *,
+        team_config: TeamConfig,
+        members: list[TeamMemberContract]) -> list[TeamMemberContract]:
+        ordered_members = self._order_members(members)
+        if self._read_response_mode(team_config) == "all_members":
+            return ordered_members
+        for member in ordered_members:
+            if member.role in {"supervisor", "planner"}:
+                return [member]
+        return ordered_members[:1]
+
+    def _read_response_mode(self, team_config: TeamConfig) -> str:
+        value = team_config.policy_json.get("response_mode")
+        if isinstance(value, str) and value in {"single_responder", "all_members"}:
+            return value
+        return "single_responder"
+
     def _build_member_input_text(
         self,
         *,
@@ -624,6 +789,32 @@ class TeamApplicationService:
             "error_message": result.run.error_message,
         }
 
+    def _member_to_json(self, member: TeamMemberContract) -> dict[str, JSONValue]:
+        return member.model_dump(mode="json")
+
+    def _team_run_to_json(self, team_run: TeamRun | None) -> dict[str, JSONValue]:
+        if team_run is None:
+            return {}
+        return {
+            "id": team_run.id,
+            "team_id": team_run.team_id,
+            "team_config_id": team_run.team_config_id,
+            "session_id": team_run.session_id,
+            "input_text": team_run.input_text,
+            "input_json": team_run.input_json,
+            "output_text": team_run.output_text,
+            "output_json": team_run.output_json,
+            "status": team_run.status,
+            "worker_key": team_run.worker_key,
+            "queued_time": team_run.queued_time,
+            "lease_expire_time": team_run.lease_expire_time,
+            "started_time": team_run.started_time,
+            "finished_time": team_run.finished_time,
+            "error_code": team_run.error_code,
+            "error_message": team_run.error_message,
+            "created_time": team_run.created_time,
+        }
+
     def _compact_prior_output(self, result: TeamMemberRunResult) -> dict[str, JSONValue]:
         return {
             "member_key": result.member.member_key,

+ 56 - 0
services/team-service/app/infrastructure/agent_client.py

@@ -1,3 +1,6 @@
+import json
+from collections.abc import Iterator
+
 import httpx
 from core_domain import AgentRunContract
 from core_shared import JSONValue
@@ -101,3 +104,56 @@ class AgentServiceClient:
                 f"agent-service execute run failed: {exc.response.status_code} {detail}") from exc
         except httpx.HTTPError as exc:
             raise AgentServiceClientError(f"agent-service execute run failed: {exc}") from exc
+
+    def execute_agent_run_stream(
+        self,
+        *,
+        agent_run_id: str,
+        worker_key: str | None,
+        dry_run: bool) -> Iterator[tuple[str, dict[str, object]]]:
+        payload: dict[str, JSONValue] = {
+            "dry_run": dry_run,
+        }
+        if worker_key is not None:
+            payload["worker_key"] = worker_key
+
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                with client.stream(
+                    "POST",
+                    f"{self.base_url}/agents/runs/{agent_run_id}/execute-stream",
+                    json=payload) as response:
+                    response.raise_for_status()
+                    yield from _iter_sse_events(response)
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500]
+            raise AgentServiceClientError(
+                f"agent-service execute stream failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise AgentServiceClientError(f"agent-service execute stream failed: {exc}") from exc
+
+
+def _iter_sse_events(response: httpx.Response) -> Iterator[tuple[str, dict[str, object]]]:
+    event_name = "message"
+    data_lines: list[str] = []
+    for line in response.iter_lines():
+        if line == "":
+            if data_lines:
+                yield event_name, _parse_json("\n".join(data_lines))
+            event_name = "message"
+            data_lines = []
+            continue
+        if line.startswith("event:"):
+            event_name = line.removeprefix("event:").strip()
+        elif line.startswith("data:"):
+            data_lines.append(line.removeprefix("data:").strip())
+    if data_lines:
+        yield event_name, _parse_json("\n".join(data_lines))
+
+
+def _parse_json(value: str) -> dict[str, object]:
+    try:
+        payload = json.loads(value)
+    except json.JSONDecodeError:
+        return {}
+    return payload if isinstance(payload, dict) else {}

+ 2 - 0
services/team-service/app/schemas/team.py

@@ -104,6 +104,7 @@ class TeamRunCreateRequest(BaseModel):
     session_id: str | None = None
     input_text: str | None = None
     input_json: dict[str, JSONValue] | None = None
+    enqueue: bool = True
 
 
 class TeamRunStatusUpdateRequest(BaseModel):
@@ -300,6 +301,7 @@ class TeamRunCreateRequestDto(BaseModel):
     sessionId: str | None = None
     inputText: str | None = None
     inputJson: dict[str, JSONValue] | None = None
+    enqueue: bool = True
 
 
 class TeamRunDetailRequestDto(BaseModel):

+ 13 - 0
services/tool-service/app/api/routes.py

@@ -14,6 +14,7 @@ from app.schemas.tool import (
     DeleteData,
     McpConnectData,
     McpConnectRequestDto,
+    McpDiscoverRequestDto,
     PageRequest,
     PageResult,
     ToolBindingCreateRequest,
@@ -281,6 +282,18 @@ def connect_mcp_server_contract(
     return ok(service.connect_mcp_server(payload))
 
 
+@router.post("/mcp/discover", response_model=ApiResponse[ToolConnectionDto])
+def discover_mcp_server_contract(
+    payload: McpDiscoverRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolConnectionDto]:
+    connection = service.discover_mcp_connection(payload)
+    if connection is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool connection not found: {payload.connectionId}")
+    return ok(ToolConnectionDto.from_entity(connection))
+
+
 @router.post("/bindings/list", response_model=ApiResponse[PageResult[ToolBindingDto]])
 def list_tool_bindings_contract(
     payload: ToolBindingListRequestDto,

+ 125 - 1
services/tool-service/app/application/services.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import json
 import urllib.error
 import urllib.request
 from datetime import datetime, timedelta
@@ -23,6 +24,7 @@ from app.domain.repositories import (
 from app.schemas.tool import (
     McpConnectData,
     McpConnectRequestDto,
+    McpDiscoverRequestDto,
     McpToolDto,
     ToolBindingCreateRequest,
     ToolBindingCreateRequestDto,
@@ -224,6 +226,11 @@ class ToolApplicationService:
             connection=ToolConnectionDto.from_entity(connection),
             discoveredTools=discovered_tools)
 
+    def discover_mcp_connection(self, payload: McpDiscoverRequestDto) -> ToolConnection | None:
+        return self._run_mcp_discovery(
+            connection_id=payload.connectionId,
+            worker_key="api-sync")
+
     def execute_mcp_discovery_job(
         self,
         *,
@@ -315,7 +322,7 @@ class ToolApplicationService:
         connection.invoke_config_json = config
         connection = self.tool_connection_repository.save(connection)
         try:
-            self._validate_mcp_connection(config)
+            discovered_tools = self._discover_mcp_tools(config)
         except Exception as exc:
             config = dict(connection.invoke_config_json or {})
             config["mcp_status"] = self._build_mcp_status(
@@ -327,6 +334,11 @@ class ToolApplicationService:
             connection.invoke_config_json = config
             return self.tool_connection_repository.save(connection)
         config = dict(connection.invoke_config_json or {})
+        if discovered_tools:
+            config["mcp_tools"] = [
+                tool.model_dump(mode="json", by_alias=False)
+                for tool in discovered_tools
+            ]
         config["mcp_status"] = self._build_mcp_status(
             job_id=resolved_job_id,
             status="completed",
@@ -534,6 +546,118 @@ class ToolApplicationService:
                     inputSchema=input_schema if isinstance(input_schema, dict) else None))
         return tools
 
+    def _discover_mcp_tools(self, config: dict[str, JSONValue]) -> list[McpToolDto]:
+        self._validate_mcp_connection(config)
+        try:
+            return self._discover_mcp_tools_via_streamable_http(config)
+        except Exception:
+            return self._extract_mcp_tools(config)
+
+    def _discover_mcp_tools_via_streamable_http(
+        self,
+        config: dict[str, JSONValue]) -> list[McpToolDto]:
+        self._mcp_rpc(
+            config,
+            request_id=1,
+            method="initialize",
+            params={
+                "protocolVersion": "2024-11-05",
+                "capabilities": {},
+                "clientInfo": {
+                    "name": "auto-platform",
+                    "version": "0.1.0",
+                },
+            })
+        self._mcp_rpc(
+            config,
+            method="notifications/initialized",
+            params={})
+        result = self._mcp_rpc(
+            config,
+            request_id=2,
+            method="tools/list",
+            params={})
+        tools_result = result.get("result")
+        if not isinstance(tools_result, dict):
+            return []
+        raw_tools = tools_result.get("tools")
+        if not isinstance(raw_tools, list):
+            return []
+        return self._parse_mcp_tool_list(raw_tools)
+
+    def _mcp_rpc(
+        self,
+        config: dict[str, JSONValue],
+        *,
+        method: str,
+        params: dict[str, JSONValue],
+        request_id: int | None = None) -> dict[str, JSONValue]:
+        url = config.get("url")
+        if not isinstance(url, str) or not url:
+            raise ValueError("MCP server url is required")
+        payload: dict[str, JSONValue] = {
+            "jsonrpc": "2.0",
+            "method": method,
+            "params": params,
+        }
+        if request_id is not None:
+            payload["id"] = request_id
+        headers = {
+            **self._read_mcp_headers(config),
+            "Accept": "application/json, text/event-stream",
+            "Content-Type": "application/json",
+        }
+        request = urllib.request.Request(
+            url,
+            data=json.dumps(payload).encode("utf-8"),
+            headers=headers,
+            method="POST")
+        with urllib.request.urlopen(
+            request,
+            timeout=self._read_timeout_seconds(config)) as response:
+            response_body = response.read().decode("utf-8")
+            content_type = response.headers.get("Content-Type", "")
+        data = self._parse_mcp_response(response_body, content_type)
+        if not data:
+            return {}
+        error = data.get("error")
+        if isinstance(error, dict):
+            message = error.get("message")
+            raise ValueError(str(message or "MCP JSON-RPC request failed"))
+        return data
+
+    def _parse_mcp_response(
+        self,
+        response_body: str,
+        content_type: str) -> dict[str, JSONValue]:
+        if "text/event-stream" in content_type:
+            data_lines: list[str] = []
+            for line in response_body.splitlines():
+                if line.startswith("data:"):
+                    data_lines.append(line[5:].strip())
+            if not data_lines:
+                return {}
+            response_body = "\n".join(data_lines)
+        parsed = json.loads(response_body)
+        return parsed if isinstance(parsed, dict) else {}
+
+    def _parse_mcp_tool_list(self, raw_tools: list[JSONValue]) -> list[McpToolDto]:
+        tools: list[McpToolDto] = []
+        for item in raw_tools:
+            if not isinstance(item, dict):
+                continue
+            name = item.get("name")
+            if not isinstance(name, str) or not name:
+                continue
+            description = item.get("description")
+            input_schema = item.get("inputSchema") or item.get("input_schema")
+            tools.append(
+                McpToolDto(
+                    name=name,
+                    description=description if isinstance(description, str) else None,
+                    inputSchema=input_schema if isinstance(input_schema, dict) else None))
+        return tools
+
     def _publish_mcp_discovery_job(
         self,
         *,

+ 4 - 0
services/tool-service/app/schemas/tool.py

@@ -341,6 +341,10 @@ class McpConnectRequestDto(BaseModel):
     config: dict[str, JSONValue]
 
 
+class McpDiscoverRequestDto(BaseModel):
+    connectionId: str
+
+
 class McpConnectData(BaseModel):
     tool: ToolDto
     connection: ToolConnectionDto

+ 1 - 1
web/src/api/client.ts

@@ -33,7 +33,7 @@ apiClient.interceptors.response.use(
   },
 );
 
-function normalizeGatewayBasePath(value: string | undefined) {
+export function normalizeGatewayBasePath(value: string | undefined) {
   const basePath = value?.trim() || "/gateway";
   if (basePath === "/" || basePath === "") return "/gateway";
   if (basePath.startsWith("http://") || basePath.startsWith("https://")) {

+ 24 - 0
web/src/api/mock.ts

@@ -1703,6 +1703,30 @@ function route(config: AxiosRequestConfig): unknown {
       discoveredTools: extractMcpTools(normalizedConfig),
     });
   }
+  if (url === "/tools/mcp/discover" && method === "post") {
+    const target = toolConnections.find((item) => item.id === payload.connectionId);
+    if (target) {
+      const existingConfig = (target.invoke_config_json ?? {}) as JSONObject;
+      const discoveredTools = extractMcpTools(existingConfig);
+      const fallbackTools = discoveredTools.length ? discoveredTools : [{
+        name: String(existingConfig.server_name ?? "mcp_tool"),
+        description: "Discovered MCP tool",
+        inputSchema: { type: "object", properties: { query: { type: "string" } } },
+      }];
+      target.invoke_config_json = {
+        ...existingConfig,
+        mcp_tools: fallbackTools,
+        mcp_status: {
+          jobId: id("mcpjob"),
+          status: "completed",
+          progress: 100,
+          updatedTime: iso(),
+          completedTime: iso(),
+        },
+      };
+    }
+    return identityResponse(toToolConnectionDto(target ?? toolConnections[0]));
+  }
   if (url === "/tools/bindings/list" && method === "post") {
     const filtered = payload.appId
       ? toolBindings.filter((item) => item.app_id === payload.appId)

+ 90 - 3
web/src/api/teams.ts

@@ -1,4 +1,6 @@
-import { apiClient } from "./client";
+import { apiClient, normalizeGatewayBasePath } from "./client";
+import { useAuthStore } from "@/stores/auth";
+import { useUiStore } from "@/stores/ui";
 import type { ApiResponse, JSONObject, PaginatedResponse, TeamConfig, TeamDefinition, TeamRun } from "@/types";
 
 interface TeamDto {
@@ -213,8 +215,8 @@ export async function deleteTeamConfig(id: string) {
   await apiClient.post("/teams/configs/delete", { configId: id });
 }
 
-export async function listTeamRuns(teamId?: string) {
-  const items = await listAllPages<TeamRunDto>("/teams/runs/list", { teamId });
+export async function listTeamRuns(teamId?: string, sessionId?: string) {
+  const items = await listAllPages<TeamRunDto>("/teams/runs/list", { teamId, sessionId });
   return items.map(toLegacyRun);
 }
 
@@ -224,6 +226,7 @@ export async function createTeamRun(payload: {
   input_text?: string | null;
   input_json?: JSONObject | null;
   session_id?: string | null;
+  enqueue?: boolean;
 }) {
   const { data } = await apiClient.post<ApiResponse<TeamRunDto>>("/teams/runs/create", {
     teamId: payload.team_id,
@@ -231,6 +234,7 @@ export async function createTeamRun(payload: {
     sessionId: payload.session_id,
     inputText: payload.input_text,
     inputJson: payload.input_json,
+    enqueue: payload.enqueue ?? true,
   });
   return toLegacyRun(unwrap(data));
 }
@@ -255,10 +259,93 @@ export async function executeTeamRun(teamRunId: string, payload: {
   };
 }
 
+export type TeamRunStreamEvent =
+  | { type: "team.run.started"; run?: TeamRun }
+  | { type: "team.member.started"; member?: JSONObject; agent_run?: JSONObject }
+  | { type: "team.member.delta"; member?: JSONObject; agent_run_id?: string; delta: string }
+  | { type: "team.member.completed"; member?: JSONObject; agent_run?: JSONObject }
+  | { type: "team.run.completed"; run?: TeamRun }
+  | { type: "team.run.failed"; run?: TeamRun }
+  | { type: string; [key: string]: unknown };
+
+export async function executeTeamRunStream(
+  teamRunId: string,
+  payload: {
+    worker_key?: string | null;
+    dry_run?: boolean;
+  },
+  onEvent: (event: TeamRunStreamEvent) => void,
+) {
+  const { gatewayBasePath } = useUiStore.getState().siteSettings;
+  const { accessToken, tokenType, userId } = useAuthStore.getState();
+  const headers = new Headers({
+    "content-type": "application/json",
+    accept: "text/event-stream",
+  });
+  if (accessToken) headers.set("Authorization", `${tokenType || "bearer"} ${accessToken}`);
+  if (userId) headers.set("x-user-id", userId);
+
+  const response = await fetch(`${normalizeGatewayBasePath(gatewayBasePath)}/teams/runs/execute-stream`, {
+    method: "POST",
+    headers,
+    body: JSON.stringify({
+      teamRunId,
+      workerKey: payload.worker_key,
+      dryRun: payload.dry_run ?? true,
+    }),
+  });
+  if (!response.ok || !response.body) {
+    throw new Error(`Team stream failed: ${response.status}`);
+  }
+
+  const reader = response.body.getReader();
+  const decoder = new TextDecoder();
+  let buffer = "";
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    buffer += decoder.decode(value, { stream: true });
+    const parts = buffer.split(/\r?\n\r?\n/);
+    buffer = parts.pop() ?? "";
+    parts.forEach((part) => {
+      const event = parseSseEvent(part);
+      if (event) onEvent(event);
+    });
+  }
+  if (buffer.trim()) {
+    const event = parseSseEvent(buffer);
+    if (event) onEvent(event);
+  }
+}
+
 export async function deleteTeamRun(id: string) {
   await apiClient.post("/teams/runs/delete", { teamRunId: id });
 }
 
+function parseSseEvent(raw: string): TeamRunStreamEvent | undefined {
+  let type = "message";
+  const dataLines: string[] = [];
+  raw.split(/\r?\n/).forEach((line) => {
+    if (line.startsWith("event:")) type = line.slice("event:".length).trim();
+    if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trim());
+  });
+  if (!dataLines.length) return undefined;
+  const payload = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
+  return { type, ...normalizeStreamPayload(payload) } as TeamRunStreamEvent;
+}
+
+function normalizeStreamPayload(payload: Record<string, unknown>) {
+  const run = payload.run;
+  return {
+    ...payload,
+    run: isRecord(run) ? (run as unknown as TeamRun) : undefined,
+  };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
 function normalizeMemberRef(member: JSONObject): JSONObject {
   const rawRole = member.role;
   const role = rawRole === "worker" ? "executor" : rawRole;

+ 8 - 0
web/src/api/tools.ts

@@ -251,6 +251,14 @@ export async function connectMcpServer(payload: {
   };
 }
 
+export async function discoverMcpConnection(connectionId: string) {
+  const { data } = await apiClient.post<ApiResponse<ToolConnectionDto>>(
+    "/tools/mcp/discover",
+    { connectionId },
+  );
+  return toLegacyConnection(unwrap(data));
+}
+
 export async function listToolBindings(appId?: string) {
   const items = await listAllPages<ToolBindingDto>(
     "/tools/bindings/list",

+ 2 - 2
web/src/components/ui/tabs.tsx

@@ -12,7 +12,7 @@ export function Tabs({
 }) {
   const active = tabs.find((tab) => tab.value === value) ?? tabs[0];
   return (
-    <div className="min-w-0">
+    <div className="flex h-full min-h-0 min-w-0 flex-col">
       <div className="overflow-x-auto rounded-md border border-border bg-muted/40 p-1">
         <div className="flex min-w-max gap-1">
           {tabs.map((tab) => (
@@ -28,7 +28,7 @@ export function Tabs({
           ))}
         </div>
       </div>
-      <div className="mt-4 min-w-0">{active?.content}</div>
+      <div className="mt-4 min-h-0 min-w-0 flex-1">{active?.content}</div>
     </div>
   );
 }

+ 12 - 0
web/src/locales/en.json

@@ -83,6 +83,7 @@
     "indexed": "Indexed",
     "ok": "OK",
     "failed": "Failed",
+    "partial_success": "Partial success",
     "disabled": "Disabled",
     "draft": "Draft",
       "archived": "Archived",
@@ -1011,6 +1012,17 @@
     "latestResult": "Latest Result",
     "memberRuns": "{{count}} member runs",
     "teamConversation": "Team conversation",
+    "messagesTab": "Messages",
+    "teamRoom": "Team Room",
+    "messageTeam": "Message the team",
+    "sendToTeam": "Send",
+    "you": "You",
+    "waitingForMembers": "Waiting for members",
+    "streamingResponse": "Team is responding...",
+    "runQueuedHint": "The task is queued. Run it now or wait for the worker to pick it up.",
+    "teamRunPartial": "Some members completed, but at least one member failed",
+    "memberRunPartialCount": "{{completed}}/{{total}} completed, {{failed}} failed",
+    "memberRunCompleteCount": "{{completed}}/{{total}} completed",
     "memberSaid": "Said",
     "none": "None",
     "lastRun": "Last Run",

+ 12 - 0
web/src/locales/zh.json

@@ -83,6 +83,7 @@
     "indexed": "已索引",
     "ok": "正常",
     "failed": "失败",
+    "partial_success": "部分成功",
     "disabled": "已禁用",
     "draft": "草稿",
       "archived": "已归档",
@@ -1011,6 +1012,17 @@
     "latestResult": "最新结果",
     "memberRuns": "{{count}} 个成员运行",
     "teamConversation": "团队对话",
+    "messagesTab": "消息",
+    "teamRoom": "团队房间",
+    "messageTeam": "给团队发送任务",
+    "sendToTeam": "发送",
+    "you": "你",
+    "waitingForMembers": "等待成员响应",
+    "streamingResponse": "团队正在响应...",
+    "runQueuedHint": "任务已排队。可以立即执行,也可以等待 worker 处理。",
+    "teamRunPartial": "部分成员已完成,但至少一个成员失败",
+    "memberRunPartialCount": "{{completed}}/{{total}} 已完成,{{failed}} 失败",
+    "memberRunCompleteCount": "{{completed}}/{{total}} 已完成",
     "memberSaid": "发言",
     "none": "无",
     "lastRun": "最近运行",

+ 294 - 87
web/src/pages/skills/SkillsPage.tsx

@@ -1,7 +1,7 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
-import { FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench } from "lucide-react";
-import { createSkill, deleteSkill, listAllSkills, listTools, updateSkill } from "@/api";
+import { CheckCircle2, Copy, FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench, X } from "lucide-react";
+import { createSkill, deleteSkill, listAllSkills, listToolConnections, listTools, updateSkill } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
@@ -15,12 +15,17 @@ import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
-import type { SkillDefinition, ToolDefinition } from "@/types";
+import type { SkillDefinition, ToolConnection, ToolDefinition } from "@/types";
 
 type ToolOption = {
   id: string;
   name: string;
   description: string;
+  toolType: string;
+  exposedTools: Array<{
+    name: string;
+    description?: string;
+  }>;
 };
 
 type SkillFormState = {
@@ -55,28 +60,37 @@ export function SkillsPage() {
 
   const toolById = React.useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]);
   const categories = Array.from(new Set(skills.map((skill) => skill.category || "service"))).sort();
-  const boundToolsCount = new Set(skills.flatMap((skill) => skill.toolIds)).size;
   const filtered = skills
     .filter((skill) => skill.status !== "archived")
     .filter((skill) => {
-      const toolNames = skill.toolIds.map((toolId) => toolById.get(toolId)?.name ?? toolId).join(" ");
+      const toolNames = skill.toolIds.map((toolId) => toolOptionSearchText(toolById.get(toolId)) || toolId).join(" ");
       const text = `${skill.name} ${skill.description ?? ""} ${skill.category} ${skill.instruction} ${toolNames}`.toLowerCase();
       return text.includes(search.toLowerCase()) && (categoryFilter === "all" || skill.category === categoryFilter);
     })
     .sort((first, second) => first.name.localeCompare(second.name));
+  const boundToolsCount = filtered.reduce((count, skill) => count + skill.toolIds.length, 0);
 
   const load = React.useCallback(async () => {
     setLoading(true);
     setError(undefined);
     try {
-      const [skillItems, toolItems] = await Promise.all([
+      const [skillItems, toolItems, connectionItems] = await Promise.all([
         listAllSkills(),
         listTools().catch(() => [] as ToolDefinition[]),
+        listToolConnections().catch(() => [] as ToolConnection[]),
       ]);
+      const connectionByTool = new Map<string, ToolConnection>();
+      connectionItems.forEach((connection) => {
+        if (!connectionByTool.has(connection.tool_id)) {
+          connectionByTool.set(connection.tool_id, connection);
+        }
+      });
       const mappedTools = toolItems.map((tool) => ({
         id: tool.id,
         name: tool.name,
         description: tool.description ?? tool.tool_type,
+        toolType: tool.tool_type,
+        exposedTools: getMcpExposedTools(connectionByTool.get(tool.id)),
       }));
       setSkills(skillItems);
       setTools(mappedTools);
@@ -207,9 +221,8 @@ export function SkillsPage() {
 
           {filtered.length ? (
             <div className="overflow-hidden rounded-md border border-border">
-              <div className="hidden grid-cols-[1.1fr_1.4fr_150px_140px_110px] gap-4 border-b border-border bg-muted/35 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground lg:grid">
+              <div className="hidden grid-cols-[minmax(260px,1fr)_150px_minmax(260px,1fr)_110px] gap-4 border-b border-border bg-muted/35 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground lg:grid">
                 <span>{t("common.name")}</span>
-                <span>{t("skills.instruction")}</span>
                 <span>{t("skills.category")}</span>
                 <span>{t("skills.toolsCount")}</span>
                 <span className="text-right">{t("common.actions")}</span>
@@ -277,8 +290,14 @@ function SkillRow({
   onDelete: () => void;
 }) {
   const { t } = useTranslation();
+  const boundTools = skill.toolIds.map((toolId) => ({
+    id: toolId,
+    label: toolDisplayName(toolById.get(toolId)) ?? toolId,
+    missing: !toolById.has(toolId),
+  }));
+
   return (
-    <div className="grid gap-3 px-4 py-4 lg:grid-cols-[1.1fr_1.4fr_150px_140px_110px] lg:items-center">
+    <div className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(260px,1fr)_150px_minmax(260px,1fr)_110px] lg:items-center">
       <div className="min-w-0">
         <div className="flex items-center gap-2">
           <Puzzle className="h-4 w-4 text-primary" />
@@ -286,15 +305,25 @@ function SkillRow({
         </div>
         <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{skill.description || t("skills.emptyHint")}</p>
       </div>
-      <p className="line-clamp-2 text-sm text-muted-foreground">{skill.instruction || t("skills.noInstruction")}</p>
       <Badge className="w-fit border-border bg-muted/50 text-muted-foreground">{categoryLabel(skill.category, t)}</Badge>
-      <div className="flex flex-wrap gap-1.5">
-        <Badge className="gap-1 border-primary/20 bg-primary/10 text-primary">
-          <Link2 className="h-3.5 w-3.5" /> {skill.toolIds.length}
-        </Badge>
-        {skill.toolIds.slice(0, 1).map((toolId) => (
-          <Badge key={toolId} className="border-border bg-muted text-muted-foreground">{toolById.get(toolId)?.name ?? toolId}</Badge>
-        ))}
+      <div className="min-w-0">
+        {boundTools.length ? (
+          <div className="flex flex-wrap gap-1.5">
+            {boundTools.slice(0, 3).map((tool) => (
+              <Badge
+                key={tool.id}
+                className={tool.missing ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-primary/20 bg-primary/10 text-primary"}
+              >
+                <Link2 className="h-3.5 w-3.5" /> <span className="max-w-32 truncate">{tool.label}</span>
+              </Badge>
+            ))}
+            {boundTools.length > 3 ? (
+              <Badge className="border-border bg-muted text-muted-foreground">+{boundTools.length - 3}</Badge>
+            ) : null}
+          </div>
+        ) : (
+          <span className="text-xs text-muted-foreground">{t("skills.noToolsSelected", "No tools selected")}</span>
+        )}
       </div>
       <div className="flex items-center justify-start gap-1.5 lg:justify-end">
         <Button size="icon" variant="ghost" onClick={onEdit} aria-label={`Edit ${skill.name}`}>
@@ -330,7 +359,25 @@ function SkillDialog({
   onSubmit: () => void;
 }) {
   const { t } = useTranslation();
+  const [toolSearch, setToolSearch] = React.useState("");
   const set = (key: keyof SkillFormState, value: string | string[]) => onChange({ ...form, [key]: value });
+  const selectedTools = form.selectedToolIds
+    .map((toolId) => tools.find((tool) => tool.id === toolId))
+    .filter((tool): tool is ToolOption => Boolean(tool));
+  const filteredTools = tools
+    .filter((tool) => {
+      const text = toolOptionSearchText(tool).toLowerCase();
+      return text.includes(toolSearch.toLowerCase().trim());
+    })
+    .sort((first, second) => {
+      const firstSelected = form.selectedToolIds.includes(first.id) ? 0 : 1;
+      const secondSelected = form.selectedToolIds.includes(second.id) ? 0 : 1;
+      return firstSelected - secondSelected || first.name.localeCompare(second.name);
+    });
+
+  React.useEffect(() => {
+    if (open) setToolSearch("");
+  }, [open]);
 
   function toggleTool(toolId: string) {
     const selectedToolIds = form.selectedToolIds.includes(toolId)
@@ -339,83 +386,212 @@ function SkillDialog({
     set("selectedToolIds", selectedToolIds);
   }
 
+  function selectVisibleTools() {
+    const next = new Set(form.selectedToolIds);
+    filteredTools.forEach((tool) => next.add(tool.id));
+    set("selectedToolIds", Array.from(next));
+  }
+
+  async function copyToolName(name: string) {
+    try {
+      await navigator.clipboard.writeText(name);
+      toast.success(t("common.copied", "Copied"));
+    } catch {
+      toast.error(t("errors.failedToCopy", "Failed to copy"));
+    }
+  }
+
   return (
-    <Dialog open={open} onOpenChange={onOpenChange} title={title} className="max-w-4xl">
-      <div className="space-y-5">
-        <div className="grid gap-4 md:grid-cols-2">
-          <Field label={t("common.name")}>
-            <Input value={form.name} onChange={(event) => set("name", event.target.value)} placeholder={t("skills.namePlaceholder")} />
-          </Field>
-          <Field label={t("skills.category")}>
-            <Select
-              value={form.category}
-              onChange={(event) => set("category", event.target.value)}
-              options={[
-                { value: "service", label: t("skills.catService") },
-                { value: "analytics", label: t("skills.catAnalytics") },
-                { value: "development", label: t("skills.catDevelopment") },
-                { value: "processing", label: t("skills.catProcessing") },
-              ]}
+    <Dialog open={open} onOpenChange={onOpenChange} title={title} className="max-w-6xl">
+      <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_430px]">
+        <div className="space-y-4">
+          <section className="rounded-md border border-border bg-surface-elevated p-4">
+            <div className="mb-4 flex items-center justify-between gap-3">
+              <div>
+                <h3 className="text-sm font-semibold">{t("skills.skillSetup", "Skill setup")}</h3>
+                <p className="mt-1 text-xs text-muted-foreground">{t("skills.skillSetupHint", "Name the skill and write the instruction it should follow.")}</p>
+              </div>
+              <Badge className={form.name.trim() ? "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-200" : "border-border bg-muted text-muted-foreground"}>
+                {form.name.trim() ? t("common.ready", "Ready") : t("common.required", "Required")}
+              </Badge>
+            </div>
+
+            <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_190px]">
+              <Field label={t("common.name")}>
+                <Input value={form.name} onChange={(event) => set("name", event.target.value)} placeholder={t("skills.namePlaceholder")} />
+              </Field>
+              <Field label={t("skills.category")}>
+                <Select
+                  value={form.category}
+                  onChange={(event) => set("category", event.target.value)}
+                  options={[
+                    { value: "service", label: t("skills.catService") },
+                    { value: "analytics", label: t("skills.catAnalytics") },
+                    { value: "development", label: t("skills.catDevelopment") },
+                    { value: "processing", label: t("skills.catProcessing") },
+                  ]}
+                />
+              </Field>
+              <div className="md:col-span-2">
+                <Field label={t("common.description")}>
+                  <Input value={form.description} onChange={(event) => set("description", event.target.value)} placeholder={t("skills.descPlaceholder")} />
+                </Field>
+              </div>
+            </div>
+          </section>
+
+          <section className="rounded-md border border-border bg-surface-elevated p-4">
+            <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+              <div>
+                <h3 className="text-sm font-semibold">{t("skills.instruction")}</h3>
+                <p className="mt-1 text-xs text-muted-foreground">{t("skills.instructionBuilderHint", "Write when to use the skill, what tools to call, and what output to return.")}</p>
+              </div>
+              <Badge className="w-fit border-border bg-muted text-muted-foreground">
+                {form.instruction.trim().length} {t("common.characters", "characters")}
+              </Badge>
+            </div>
+            <Textarea
+              value={form.instruction}
+              onChange={(event) => set("instruction", event.target.value)}
+              placeholder={t("skills.instructionPlaceholder")}
+              className="min-h-72 font-mono leading-6"
             />
-          </Field>
-          <div className="md:col-span-2">
-            <Field label={t("common.description")}>
-              <Input value={form.description} onChange={(event) => set("description", event.target.value)} placeholder={t("skills.descPlaceholder")} />
-            </Field>
-          </div>
-          <div className="md:col-span-2">
-            <Field label={t("skills.instruction")}>
-              <Textarea
-                value={form.instruction}
-                onChange={(event) => set("instruction", event.target.value)}
-                placeholder={t("skills.instructionPlaceholder")}
-                className="min-h-36 font-mono"
-              />
-            </Field>
-          </div>
+          </section>
         </div>
 
-        <div>
-          <div className="mb-2">
-            <p className="text-sm font-medium">{t("skills.selectTools")}</p>
-            <p className="text-xs text-muted-foreground">{t("skills.toolsHint")}</p>
-          </div>
-          <div className="grid max-h-60 gap-2 overflow-auto rounded-md border border-border p-2 sm:grid-cols-2">
-            {tools.length ? tools.map((tool) => {
-              const selected = form.selectedToolIds.includes(tool.id);
-              return (
-                <button
-                  key={tool.id}
-                  type="button"
-                  onClick={() => toggleTool(tool.id)}
-                  className={[
-                    "rounded-md border p-3 text-left transition hover:bg-muted",
-                    selected ? "border-primary/40 bg-primary/10" : "border-border bg-muted/20",
-                  ].join(" ")}
-                >
-                  <div className="flex items-start gap-3">
-                    <span className={[
-                      "mt-0.5 grid h-5 w-5 place-items-center rounded border",
-                      selected ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/40",
-                    ].join(" ")}>
-                      {selected ? <FileText className="h-3 w-3" /> : null}
-                    </span>
-                    <span className="min-w-0">
-                      <span className="block truncate text-sm font-medium">{tool.name}</span>
-                      <span className="mt-1 block line-clamp-2 text-xs text-muted-foreground">{tool.description}</span>
-                    </span>
+        <aside className="space-y-4">
+          <section className="rounded-md border border-border bg-surface-elevated">
+            <div className="border-b border-border p-4">
+              <div className="flex items-start justify-between gap-3">
+                <div>
+                  <h3 className="text-sm font-semibold">{t("skills.selectTools")}</h3>
+                  <p className="mt-1 text-xs text-muted-foreground">{t("skills.toolsHint")}</p>
+                </div>
+                <Badge className="shrink-0 border-primary/20 bg-primary/10 text-primary">
+                  {form.selectedToolIds.length}
+                </Badge>
+              </div>
+
+              <div className="mt-3 grid gap-2">
+                <SearchInput
+                  value={toolSearch}
+                  onChange={setToolSearch}
+                  placeholder={t("skills.searchTools", "Search tools")}
+                />
+                <div className="grid gap-2 sm:grid-cols-2">
+                  <Button type="button" variant="outline" onClick={selectVisibleTools} disabled={!filteredTools.length}>
+                    <CheckCircle2 className="h-4 w-4" /> {t("common.selectAll", "Select all")}
+                  </Button>
+                  <Button type="button" variant="ghost" onClick={() => set("selectedToolIds", [])} disabled={!form.selectedToolIds.length}>
+                    <X className="h-4 w-4" /> {t("common.clear", "Clear")}
+                  </Button>
+                </div>
+              </div>
+
+              <div className="mt-3 min-h-10 rounded-md border border-dashed border-border bg-background/60 p-2">
+                {selectedTools.length ? (
+                  <div className="flex flex-wrap gap-1.5">
+                    {selectedTools.map((tool) => (
+                      <button
+                        key={tool.id}
+                        type="button"
+                        onClick={() => toggleTool(tool.id)}
+                        className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary/15"
+                      >
+                        <span className="truncate">{toolDisplayName(tool)}</span>
+                        <X className="h-3 w-3 shrink-0" />
+                      </button>
+                    ))}
                   </div>
-                </button>
-              );
-            }) : (
-              <div className="rounded-md border border-dashed border-border bg-muted/20 p-4 text-sm text-muted-foreground sm:col-span-2">
-                {t("skills.noTools")}
+                ) : (
+                  <p className="px-1 py-1 text-xs text-muted-foreground">
+                    {t("skills.noToolsSelected", "No tools selected")}
+                  </p>
+                )}
               </div>
-            )}
-          </div>
-        </div>
+            </div>
 
-        <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
+            <div className="max-h-[46dvh] overflow-auto">
+              {filteredTools.length ? (
+                <div className="divide-y divide-border">
+                  {filteredTools.map((tool) => {
+                    const selected = form.selectedToolIds.includes(tool.id);
+                    const displayName = toolDisplayName(tool) ?? tool.name;
+                    return (
+                      <div
+                        key={tool.id}
+                        className={[
+                          "grid grid-cols-[24px_minmax(0,1fr)] gap-3 px-3 py-3 transition hover:bg-muted/35",
+                          selected ? "bg-primary/5" : "bg-transparent",
+                        ].join(" ")}
+                      >
+                        <button
+                          type="button"
+                          onClick={() => toggleTool(tool.id)}
+                          className={[
+                            "mt-0.5 grid h-5 w-5 place-items-center rounded border",
+                            selected ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/40",
+                          ].join(" ")}
+                          aria-label={selected ? t("common.selected", "Selected") : t("skills.selectTools")}
+                        >
+                          {selected ? <CheckCircle2 className="h-3.5 w-3.5" /> : null}
+                        </button>
+                        <div className="min-w-0">
+                          <button type="button" className="block w-full text-left" onClick={() => toggleTool(tool.id)}>
+                            <span className="flex min-w-0 items-center gap-2">
+                              <span className="truncate text-sm font-medium">{displayName}</span>
+                              <Badge className="shrink-0 border-border bg-muted text-muted-foreground">
+                                {tool.toolType === "mcp" ? t("tools.mcpServer", "MCP server") : tool.toolType}
+                              </Badge>
+                            </span>
+                            <span className="mt-1 block line-clamp-2 text-xs text-muted-foreground">
+                              {tool.exposedTools.length
+                                ? `${tool.name} / ${tool.exposedTools.length} ${t("tools.exposedTools", "exposed tools")}`
+                                : tool.description}
+                            </span>
+                          </button>
+
+                          <div className="mt-2 flex flex-wrap gap-1.5">
+                            {(tool.exposedTools.length ? tool.exposedTools.slice(0, 4) : [{ name: displayName }]).map((exposedTool) => (
+                              <span
+                                key={exposedTool.name}
+                                className="inline-flex items-center gap-1 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
+                              >
+                                {exposedTool.name}
+                                <button
+                                  type="button"
+                                  className="grid h-5 w-5 place-items-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
+                                  aria-label={t("skills.copyToolName", "Copy tool name")}
+                                  onClick={() => void copyToolName(exposedTool.name)}
+                                >
+                                  <Copy className="h-3 w-3" />
+                                </button>
+                              </span>
+                            ))}
+                            {tool.exposedTools.length > 4 ? (
+                              <span className="text-xs text-muted-foreground">+{tool.exposedTools.length - 4}</span>
+                            ) : null}
+                          </div>
+                        </div>
+                      </div>
+                    );
+                  })}
+                </div>
+              ) : (
+                <div className="p-4 text-sm text-muted-foreground">
+                  {tools.length ? t("skills.noToolsMatch", "No tools match this search") : t("skills.noTools")}
+                </div>
+              )}
+            </div>
+          </section>
+        </aside>
+      </div>
+
+      <div className="sticky bottom-0 -mx-4 -mb-4 mt-5 flex flex-col-reverse gap-3 border-t border-border bg-surface-elevated p-4 sm:-mx-5 sm:-mb-5 sm:flex-row sm:items-center sm:justify-between">
+        <div className="text-xs text-muted-foreground">
+          {form.name.trim() || t("skills.namePlaceholder")} / {form.selectedToolIds.length} {t("skills.toolsCount")}
+        </div>
+        <div className="flex flex-col-reverse gap-2 sm:flex-row">
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
           <Button type="button" onClick={onSubmit} disabled={!form.name.trim() || saving}>{saving ? t("common.saving") : submitLabel}</Button>
         </div>
@@ -443,6 +619,37 @@ function fromSkill(skill: SkillDefinition): SkillFormState {
   };
 }
 
+function toolDisplayName(tool?: ToolOption) {
+  if (!tool) return undefined;
+  const onlyExposedTool = tool.exposedTools[0];
+  if (tool.exposedTools.length === 1 && onlyExposedTool) return onlyExposedTool.name;
+  return tool.name;
+}
+
+function toolOptionSearchText(tool?: ToolOption) {
+  if (!tool) return "";
+  const exposedText = tool.exposedTools
+    .map((exposedTool) => `${exposedTool.name} ${exposedTool.description ?? ""}`)
+    .join(" ");
+  return `${tool.name} ${tool.description} ${tool.toolType} ${exposedText}`;
+}
+
+function getMcpExposedTools(connection?: ToolConnection) {
+  const config = connection?.invoke_config_json;
+  if (!config || typeof config !== "object") return [];
+  const rawTools = config.mcp_tools ?? config.tools ?? config.tool_names;
+  if (!Array.isArray(rawTools)) return [];
+  return rawTools.flatMap((item) => {
+    if (!item || typeof item !== "object" || Array.isArray(item)) return [];
+    const record = item as Record<string, unknown>;
+    if (typeof record.name !== "string" || !record.name) return [];
+    return [{
+      name: record.name,
+      description: typeof record.description === "string" ? record.description : undefined,
+    }];
+  });
+}
+
 function categoryLabel(category: string, t: (key: string) => string) {
   const key = `skills.cat${formatCategoryKey(category)}`;
   const label = t(key);

+ 105 - 72
web/src/pages/teams/TeamsPage.tsx

@@ -1,9 +1,7 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 import {
-  Activity,
   Bot,
-  CheckCircle2,
   Clock,
   Pencil,
   RefreshCw,
@@ -16,7 +14,6 @@ import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
-import { MetricCard } from "@/components/shared/MetricCard";
 import { PageHeader } from "@/components/shared/PageHeader";
 import { SearchInput } from "@/components/shared/SearchInput";
 import { StatusBadge } from "@/components/shared/StatusBadge";
@@ -24,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { Select } from "@/components/ui/select";
+import { Tabs } from "@/components/ui/tabs";
 import { toast } from "@/components/ui/toaster";
 import { cn, relativeTime } from "@/lib/utils";
 import type { AgentDefinition, TeamConfig, TeamDefinition, TeamRun, TeamStatus } from "@/types";
@@ -33,6 +31,7 @@ import { TeamRuns } from "./components/TeamRuns";
 
 type StatusFilter = "all" | TeamStatus;
 type SortMode = "recent" | "name" | "status";
+type TeamWorkspaceTab = "messages" | "basic";
 
 export function TeamsPage() {
   const { t } = useTranslation();
@@ -49,6 +48,8 @@ export function TeamsPage() {
   const [relatedLoading, setRelatedLoading] = React.useState(false);
   const [createOpen, setCreateOpen] = React.useState(false);
   const [editOpen, setEditOpen] = React.useState(false);
+  const [workspaceTab, setWorkspaceTab] = React.useState<TeamWorkspaceTab>("basic");
+  const [selectedTeamSessionId, setSelectedTeamSessionId] = React.useState<string>();
 
   const selectedTeam = teams.find((team) => team.id === selectedTeamId);
   const sortedConfigs = React.useMemo(
@@ -61,7 +62,6 @@ export function TeamsPage() {
   );
   const activeConfig = sortedConfigs[0];
   const failedRunCount = sortedRuns.filter((run) => run.status === "failed").length;
-  const activeTeams = teams.filter((team) => team.status === "active").length;
   const draftTeams = teams.filter((team) => team.status === "draft").length;
   const agentNameById = React.useMemo(() => {
     const result = new Map<string, string>();
@@ -103,12 +103,15 @@ export function TeamsPage() {
   }, [t]);
 
   const reloadDetails = React.useCallback(async () => {
-    if (!selectedTeamId) return;
+    if (!selectedTeamId || !selectedTeamSessionId) return;
     setRelatedLoading(true);
     setConfigs([]);
     setRuns([]);
     try {
-      const [configData, runData] = await Promise.all([listTeamConfigs(selectedTeamId), listTeamRuns(selectedTeamId)]);
+      const [configData, runData] = await Promise.all([
+        listTeamConfigs(selectedTeamId),
+        listTeamRuns(selectedTeamId, selectedTeamSessionId),
+      ]);
       setConfigs(configData);
       setRuns(runData);
     } catch {
@@ -116,10 +119,13 @@ export function TeamsPage() {
     } finally {
       setRelatedLoading(false);
     }
-  }, [selectedTeamId, t]);
+  }, [selectedTeamId, selectedTeamSessionId, t]);
 
   React.useEffect(() => { void load(); }, [load]);
-  React.useEffect(() => { if (selectedTeamId) void reloadDetails(); }, [selectedTeamId, reloadDetails]);
+  React.useEffect(() => {
+    if (selectedTeamId) setSelectedTeamSessionId(getOrCreateTeamSessionId(selectedTeamId));
+  }, [selectedTeamId]);
+  React.useEffect(() => { if (selectedTeamId && selectedTeamSessionId) void reloadDetails(); }, [selectedTeamId, selectedTeamSessionId, reloadDetails]);
   React.useEffect(() => { if (!selectedTeamId && teams[0]) setSelectedTeamId(teams[0].id); }, [selectedTeamId, teams]);
 
   function clearFilters() {
@@ -132,7 +138,7 @@ export function TeamsPage() {
   if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
 
   return (
-    <div className="space-y-6">
+    <div className="flex min-h-0 flex-col gap-6">
       <PageHeader
         title={t("teams.title")}
         description={t("teams.description")}
@@ -148,16 +154,9 @@ export function TeamsPage() {
         }
       />
 
-      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
-        <MetricCard label={t("teams.title")} value={teams.length} icon={Users} />
-        <MetricCard label={t("common.active")} value={activeTeams} icon={CheckCircle2} />
-        <MetricCard label={t("common.runs")} value={sortedRuns.length} icon={Activity} />
-        <MetricCard label={t("teams.failedRuns")} value={failedRunCount} icon={Activity} />
-      </div>
-
-      <div className="grid gap-6 xl:grid-cols-[460px_1fr]">
-        <Card className="overflow-hidden">
-          <CardHeader>
+      <div className="grid h-[calc(100dvh-180px)] min-h-[620px] gap-5 xl:grid-cols-[320px_minmax(0,1fr)]">
+        <Card className="min-h-0 overflow-hidden">
+          <CardHeader className="p-4">
             <div className="flex items-start justify-between gap-3">
               <div>
                 <CardTitle>{t("teams.teamDirectory")}</CardTitle>
@@ -168,9 +167,9 @@ export function TeamsPage() {
               <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
             </div>
           </CardHeader>
-          <CardContent className="space-y-4">
+          <CardContent className="space-y-3 p-4 pt-0">
             <SearchInput value={search} onChange={setSearch} placeholder="Search by name, type, or description" />
-            <div className="grid gap-3 sm:grid-cols-2">
+            <div className="grid gap-3">
               <Select
                 aria-label={t("teams.filterByStatus")}
                 value={statusFilter}
@@ -200,7 +199,7 @@ export function TeamsPage() {
             ) : null}
 
             {filtered.length ? (
-              <div className="space-y-3">
+              <div className="max-h-[calc(100vh-390px)] space-y-2 overflow-auto pr-1">
                 {filtered.map((team) => (
                   <TeamDirectoryItem
                     key={team.id}
@@ -217,59 +216,84 @@ export function TeamsPage() {
           </CardContent>
         </Card>
 
-        <Card className="min-w-0 overflow-hidden">
-          <CardHeader className="border-b border-border bg-muted/15 p-5">
-            <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
-              <div className="min-w-0">
-                <div className="flex min-w-0 flex-wrap items-center gap-2">
-                  <CardTitle className="truncate text-lg">{selectedTeam?.name ?? t("teams.teamCockpit")}</CardTitle>
-                  {selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
+        <div className="min-h-0 min-w-0">
+          <Card className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
+            <CardHeader className="border-b border-border bg-muted/15 p-5">
+              <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
+                <div className="min-w-0">
+                  <div className="flex min-w-0 flex-wrap items-center gap-2">
+                    <CardTitle className="truncate text-lg">{selectedTeam?.name ?? t("teams.teamCockpit")}</CardTitle>
+                    {selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
+                  </div>
+                  <CardDescription className="mt-1">
+                    {selectedTeam ? teamValueLabel(t, selectedTeam.team_type) : t("teams.selectTeamInspect")}
+                  </CardDescription>
+                </div>
+                <div className="flex flex-wrap items-center gap-2">
+                  {selectedTeam && activeConfig ? (
+                    <Badge className="border-border bg-surface-elevated text-muted-foreground">
+                      {sortedRuns.length} {t("common.runs")}
+                    </Badge>
+                  ) : null}
+                  {selectedTeam ? (
+                    <Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
+                      <Pencil className="h-4 w-4" /> {t("common.edit")}
+                    </Button>
+                  ) : null}
                 </div>
-                <CardDescription className="mt-1">
-                  {selectedTeam ? teamValueLabel(t, selectedTeam.team_type) : t("teams.selectTeamInspect")}
-                </CardDescription>
-              </div>
-              <div className="flex flex-wrap items-center gap-2">
-                {selectedTeam && activeConfig ? (
-                  <Badge className="border-border bg-surface-elevated text-muted-foreground">
-                    {sortedRuns.length} {t("common.runs")}
-                  </Badge>
-                ) : null}
-                {selectedTeam ? (
-                  <Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
-                    <Pencil className="h-4 w-4" /> {t("common.edit")}
-                  </Button>
-                ) : null}
               </div>
-            </div>
-          </CardHeader>
-          <CardContent className="p-4">
-            {selectedTeam ? (
-              <div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1fr)_380px]">
-                <TeamOverview
-                  team={selectedTeam}
-                  activeConfig={activeConfig}
-                  runCount={sortedRuns.length}
-                  failedRunCount={failedRunCount}
-                  latestRun={sortedRuns[0]}
-                  agentNameById={agentNameById}
-                />
-                <TeamRuns
-                  teamId={selectedTeam.id}
-                  configs={sortedConfigs}
-                  runs={sortedRuns}
-                  loading={relatedLoading}
-                  agentNameById={agentNameById}
-                  onRunCreated={(run) => {
-                    setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
-                  }}
+            </CardHeader>
+            <CardContent className="min-h-0 flex-1 p-5">
+              {selectedTeam ? (
+                <Tabs
+                  value={workspaceTab}
+                  onChange={(value) => setWorkspaceTab(value as TeamWorkspaceTab)}
+                  tabs={[
+                    {
+                      value: "basic",
+                      label: t("teams.basicInfo"),
+                      content: (
+                        <div className="min-h-0 overflow-auto pr-1">
+                          <TeamOverview
+                            team={selectedTeam}
+                            activeConfig={activeConfig}
+                            runCount={sortedRuns.length}
+                            failedRunCount={failedRunCount}
+                            latestRun={sortedRuns[0]}
+                            agentNameById={agentNameById}
+                          />
+                        </div>
+                      ),
+                    },
+                    {
+                      value: "messages",
+                      label: t("teams.messagesTab"),
+                      content: (
+                        <div className="h-full min-h-0">
+                          <TeamRuns
+                            teamId={selectedTeam.id}
+                            sessionId={selectedTeamSessionId}
+                            configs={sortedConfigs}
+                            runs={sortedRuns}
+                            loading={relatedLoading}
+                            agentNameById={agentNameById}
+                            onRunCreated={(run) => {
+                              setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
+                            }}
+                          />
+                        </div>
+                      ),
+                    },
+                  ]}
                 />
-              </div>
-            ) : (
-              <EmptyState icon={Bot} title={t("teams.noTeams")} description={t("teams.createTeamStart")} actionLabel={t("teams.newTeam")} onAction={() => setCreateOpen(true)} />
-            )}
-          </CardContent>
-        </Card>
+              ) : (
+                <div className="p-6">
+                  <EmptyState icon={Bot} title={t("teams.noTeams")} description={t("teams.createTeamStart")} actionLabel={t("teams.newTeam")} onAction={() => setCreateOpen(true)} />
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
       </div>
 
       <CreateTeamDialog
@@ -354,3 +378,12 @@ const TEAM_VALUE_LABEL_KEYS: Record<string, string> = {
   pipeline: "pipeline",
   supervisor: "supervisor",
 };
+
+function getOrCreateTeamSessionId(teamId: string) {
+  const key = `auto-platform.team-session.${teamId}`;
+  const existing = window.localStorage.getItem(key);
+  if (existing) return existing;
+  const sessionId = crypto.randomUUID();
+  window.localStorage.setItem(key, sessionId);
+  return sessionId;
+}

File diff ditekan karena terlalu besar
+ 670 - 197
web/src/pages/teams/components/TeamRuns.tsx


+ 2 - 0
web/src/pages/tools/ToolsPage.tsx

@@ -399,6 +399,8 @@ function getMcpExposedTools(connection?: ToolConnection): McpExposedTool[] {
       description: typeof record.description === "string" ? record.description : undefined,
       inputSchema: record.inputSchema && typeof record.inputSchema === "object"
         ? record.inputSchema as Record<string, unknown>
+        : record.input_schema && typeof record.input_schema === "object"
+          ? record.input_schema as Record<string, unknown>
         : undefined,
     });
   }

+ 82 - 4
web/src/pages/tools/components/ToolDetailSheet.tsx

@@ -1,7 +1,9 @@
 import * as React from "react";
-import { Wrench } from "lucide-react";
+import { RefreshCw, Wrench } from "lucide-react";
 import { useTranslation } from "react-i18next";
-import { listToolConnections } from "@/api";
+import { discoverMcpConnection, listToolConnections } from "@/api";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
 import { Sheet } from "@/components/ui/sheet";
 import { toast } from "@/components/ui/toaster";
 import type { ToolConnection, ToolDefinition } from "@/types";
@@ -21,6 +23,7 @@ interface ToolDetailSheetProps {
 export function ToolDetailSheet({ tool, open, onOpenChange }: ToolDetailSheetProps) {
   const { t } = useTranslation();
   const [connections, setConnections] = React.useState<ToolConnection[]>([]);
+  const [discovering, setDiscovering] = React.useState(false);
 
   const loadData = React.useCallback(async () => {
     try {
@@ -38,6 +41,21 @@ export function ToolDetailSheet({ tool, open, onOpenChange }: ToolDetailSheetPro
   const activeConnection = connections[0];
   const isMcp = tool.tool_type === "mcp";
   const mcpTools = isMcp ? getMcpToolsFromConfig(activeConnection?.invoke_config_json) : [];
+  const mcpStatus = readMcpStatus(activeConnection?.invoke_config_json);
+
+  async function rediscover() {
+    if (!activeConnection) return;
+    setDiscovering(true);
+    try {
+      const updated = await discoverMcpConnection(activeConnection.id);
+      setConnections((items) => items.map((item) => (item.id === updated.id ? updated : item)));
+      toast.success(t("tools.discoveryCompleted", "MCP discovery completed"));
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t("tools.failedToDiscoverMcp", "Failed to discover MCP tools"));
+    } finally {
+      setDiscovering(false);
+    }
+  }
 
   return (
     <Sheet
@@ -47,7 +65,32 @@ export function ToolDetailSheet({ tool, open, onOpenChange }: ToolDetailSheetPro
       description={t("tools.availableToolCount", { count: mcpTools.length })}
       className="max-w-2xl"
     >
-      <div className="space-y-3">
+      <div className="space-y-4">
+        {activeConnection ? (
+          <div className="rounded-md border border-border bg-muted/20 p-3">
+            <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+              <div>
+                <p className="font-mono text-sm">{getToolEndpoint(activeConnection) || t("tools.notConfigured")}</p>
+                <div className="mt-2 flex flex-wrap gap-2">
+                  <Badge className={statusBadgeClass(mcpStatus.status)}>
+                    {mcpStatus.status || t("tools.unknownStatus", "unknown")}
+                  </Badge>
+                  <Badge className="border-border bg-surface-elevated text-muted-foreground">
+                    {t("tools.availableToolCount", { count: mcpTools.length })}
+                  </Badge>
+                </div>
+                {mcpStatus.errorMessage ? (
+                  <p className="mt-2 text-xs text-destructive">{mcpStatus.errorMessage}</p>
+                ) : null}
+              </div>
+              <Button type="button" variant="outline" onClick={() => void rediscover()} disabled={discovering}>
+                <RefreshCw className={`h-4 w-4 ${discovering ? "animate-spin" : ""}`} />
+                {discovering ? t("tools.discovering", "Discovering") : t("tools.rediscover", "Rediscover")}
+              </Button>
+            </div>
+          </div>
+        ) : null}
+
         {mcpTools.length > 0 ? (
           mcpTools.map((mcpTool) => (
             <McpToolDetail key={mcpTool.name} tool={mcpTool} />
@@ -62,7 +105,7 @@ export function ToolDetailSheet({ tool, open, onOpenChange }: ToolDetailSheetPro
 
 function McpToolDetail({ tool }: { tool: McpExposedTool }) {
   const { t } = useTranslation();
-  const params = tool.inputSchema ? Object.entries(tool.inputSchema) : [];
+  const params = getSchemaProperties(tool.inputSchema);
 
   return (
     <div className="rounded-md border border-border bg-background p-3">
@@ -92,6 +135,41 @@ function McpToolDetail({ tool }: { tool: McpExposedTool }) {
   );
 }
 
+function getSchemaProperties(schema?: Record<string, unknown>): Array<[string, unknown]> {
+  if (!schema) return [];
+  const properties = schema.properties;
+  if (properties && typeof properties === "object" && !Array.isArray(properties)) {
+    return Object.entries(properties as Record<string, unknown>);
+  }
+  return Object.entries(schema);
+}
+
+function getToolEndpoint(connection?: ToolConnection) {
+  const config = connection?.invoke_config_json;
+  if (!config || typeof config !== "object") return undefined;
+  const url = config.url;
+  return typeof url === "string" ? url : undefined;
+}
+
+function readMcpStatus(config?: Record<string, unknown> | null) {
+  const status = config?.mcp_status;
+  if (!status || typeof status !== "object" || Array.isArray(status)) {
+    return {} as { status?: string; errorMessage?: string };
+  }
+  const record = status as Record<string, unknown>;
+  return {
+    status: typeof record.status === "string" ? record.status : undefined,
+    errorMessage: typeof record.errorMessage === "string" ? record.errorMessage : undefined,
+  };
+}
+
+function statusBadgeClass(status?: string) {
+  if (status === "completed") return "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-200";
+  if (status === "failed") return "border-destructive/30 bg-destructive/10 text-destructive";
+  if (status === "running" || status === "queued") return "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-200";
+  return "border-border bg-surface-elevated text-muted-foreground";
+}
+
 function getMcpToolsFromConfig(config?: Record<string, unknown> | null): McpExposedTool[] {
   if (!config) return [];
   const tools = config.mcp_tools ?? config.tools ?? config.tool_names;

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini