浏览代码

feat: add session runtime targets and SSE streaming execution

Add runtime target fields (agent/team) to sessions with migration,
implement SSE streaming for session execute via gateway orchestration,
update frontend to stream assistant responses in real-time with typing
indicator, rename platform to AgentDock, remove unused core-dsl dep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Jax Docker 1 月之前
父节点
当前提交
2f826a8c6b

+ 0 - 1
deployments/docker/python-service.Dockerfile

@@ -15,7 +15,6 @@ COPY services ./services
 RUN pip install --no-cache-dir \
     -e ./libs/core-shared \
     -e ./libs/core-domain \
-    -e ./libs/core-dsl \
     -e ./libs/core-events \
     -e ./libs/core-db \
     -e ./${SERVICE_PATH}

+ 515 - 1
services/api-gateway/app/api/routes.py

@@ -1,16 +1,20 @@
 import asyncio
+import json
 from typing import Annotated
 
 from core_domain import ServiceDescriptor, ServiceHealth
-from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
+import httpx
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, StreamingResponse
 from sqlalchemy import text
 from sqlalchemy.orm import Session
+from pydantic import BaseModel
 
 from app.bootstrap.settings import ApiGatewaySettings
 from app.db.session import get_db
 from app.domain.repositories import ApiKeyRepository, GatewayRequestAuditRepository
 from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
 from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
+from core_shared.security import build_internal_service_headers
 from app.schemas.gateway import (
     ApiKeyCreateRequest,
     ApiKeyCreateResponse,
@@ -28,6 +32,27 @@ router = APIRouter()
 DbSession = Annotated[Session, Depends(get_db)]
 
 
+class SessionExecuteRequest(BaseModel):
+    session_id: str
+    message_text: str
+    stream: bool = False
+
+
+class SessionExecuteResponse(BaseModel):
+    session_id: str
+    run_request_id: str
+    target_type: str
+    target_id: str
+    target_config_id: str | None = None
+    request_status: str
+    user_message_id: str
+    assistant_message_id: str | None = None
+    agent_run_id: str | None = None
+    team_run_id: str | None = None
+    output_text: str | None = None
+    error_message: str | None = None
+
+
 @router.get("/health", response_model=ServiceDescriptor)
 def health_check(db: DbSession) -> ServiceDescriptor:
     db.execute(text("SELECT 1"))
@@ -241,6 +266,372 @@ async def downstream_health_check(
         downstream_services=downstream_services)
 
 
+@router.post("/gateway/sessions/execute")
+async def execute_session(
+    payload: SessionExecuteRequest,
+    request: Request,
+    settings: GatewaySettingsDep):
+    if payload.stream:
+        return StreamingResponse(
+            _stream_session_execute(payload, request, settings),
+            media_type="text/event-stream",
+            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+
+    targets = build_proxy_targets(settings)
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    team_target = targets["team-service"]
+    headers = _build_internal_headers(request, settings)
+
+    async with httpx_client(settings.proxy_timeout_seconds) as client:
+        session = await _post_json(
+            client=client,
+            target=session_target,
+            path="detail",
+            payload={"session_id": payload.session_id},
+            headers=headers)
+
+        target_type = _get_string(session, "runtime_target_type")
+        target_id = _get_string(session, "runtime_target_id")
+        target_config_id = _get_optional_string(session, "runtime_target_config_id")
+        if target_type not in {"agent", "team"} or not target_id:
+            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+
+        run_request_payload = {
+            "target_type": target_type,
+            "target_id": target_id,
+            "target_config_id": target_config_id,
+            "mode": "production",
+            "input_text": payload.message_text,
+        }
+        run_request = await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests",
+            payload={
+                "session_id": payload.session_id,
+                "app_config_id": target_config_id or target_id,
+                "workflow_config_id": target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            },
+            headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client,
+            target=session_target,
+            path="messages",
+            payload={
+                "session_id": payload.session_id,
+                "turn_id": run_request_id,
+                "role": "user",
+                "content_type": "text",
+                "content_text": payload.message_text,
+                "content_json": {},
+            },
+            headers=headers)
+        user_message_id = _get_string(user_message, "id")
+
+        await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id,
+                "request_status": "running",
+                "request_payload_json": {
+                    **run_request_payload,
+                    "user_message_id": user_message_id,
+                },
+            },
+            headers=headers)
+
+        assistant_message_id: str | None = None
+        agent_run_id: str | None = None
+        team_run_id: str | None = None
+        output_text: str | None = None
+        error_message: str | None = None
+        request_status = "completed"
+
+        try:
+            if target_type == "agent":
+                agent_run = await _post_json(
+                    client=client,
+                    target=agent_target,
+                    path="runs",
+                    payload={
+                        "agent_id": target_id,
+                        "agent_config_id": target_config_id,
+                        "session_id": payload.session_id,
+                        "input_text": payload.message_text,
+                        "input_json": {
+                            "source": "session",
+                            "run_request_id": run_request_id,
+                        },
+                    },
+                    headers=headers)
+                agent_run_id = _get_string(agent_run, "id")
+                execute_result = await _post_json(
+                    client=client,
+                    target=agent_target,
+                    path="runs/execute",
+                    payload={
+                        "agent_run_id": agent_run_id,
+                        "dry_run": False,
+                    },
+                    headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+            else:
+                team_run = await _post_json(
+                    client=client,
+                    target=team_target,
+                    path="runs",
+                    payload={
+                        "team_id": target_id,
+                        "team_config_id": target_config_id,
+                        "session_id": payload.session_id,
+                        "input_text": payload.message_text,
+                        "input_json": {
+                            "source": "session",
+                            "run_request_id": run_request_id,
+                        },
+                        "enqueue": True,
+                    },
+                    headers=headers)
+                team_run_id = _get_string(team_run, "id")
+                execute_result = await _post_json(
+                    client=client,
+                    target=team_target,
+                    path=f"runs/{team_run_id}/execute",
+                    payload={
+                        "dry_run": False,
+                    },
+                    headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+
+            if error_message:
+                request_status = "failed"
+
+            if output_text:
+                assistant_message = await _post_json(
+                    client=client,
+                    target=session_target,
+                    path="messages",
+                    payload={
+                        "session_id": payload.session_id,
+                        "turn_id": run_request_id,
+                        "role": "assistant",
+                        "content_type": "text",
+                        "content_text": output_text,
+                        "content_json": {},
+                    },
+                    headers=headers)
+                assistant_message_id = _get_string(assistant_message, "id")
+        except HTTPException as exc:
+            request_status = "failed"
+            error_message = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+
+        await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id,
+                "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload,
+                    "user_message_id": user_message_id,
+                    "assistant_message_id": assistant_message_id,
+                    "agent_run_id": agent_run_id,
+                    "team_run_id": team_run_id,
+                    "output_text": output_text,
+                    "error_message": error_message,
+                },
+            },
+            headers=headers)
+
+    return SessionExecuteResponse(
+        session_id=payload.session_id,
+        run_request_id=run_request_id,
+        target_type=target_type,
+        target_id=target_id,
+        target_config_id=target_config_id,
+        request_status=request_status,
+        user_message_id=user_message_id,
+        assistant_message_id=assistant_message_id,
+        agent_run_id=agent_run_id,
+        team_run_id=team_run_id,
+        output_text=output_text,
+        error_message=error_message)
+
+
+async def _stream_session_execute(
+    payload: SessionExecuteRequest,
+    request: Request,
+    settings: ApiGatewaySettings):
+    targets = build_proxy_targets(settings)
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    team_target = targets["team-service"]
+    headers = _build_internal_headers(request, settings)
+    client = httpx.AsyncClient(timeout=settings.proxy_timeout_seconds)
+
+    try:
+        session = await _post_json(
+            client=client, target=session_target, path="detail",
+            payload={"session_id": payload.session_id}, headers=headers)
+        target_type = _get_string(session, "runtime_target_type")
+        target_id = _get_string(session, "runtime_target_id")
+        target_config_id = _get_optional_string(session, "runtime_target_config_id")
+        if target_type not in {"agent", "team"} or not target_id:
+            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+
+        run_request_payload = {
+            "target_type": target_type, "target_id": target_id,
+            "target_config_id": target_config_id, "mode": "production",
+            "input_text": payload.message_text,
+        }
+        run_request = await _post_json(
+            client=client, target=session_target, path="run-requests",
+            payload={
+                "session_id": payload.session_id,
+                "app_config_id": target_config_id or target_id,
+                "workflow_config_id": target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            }, headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client, target=session_target, path="messages",
+            payload={
+                "session_id": payload.session_id, "turn_id": run_request_id,
+                "role": "user", "content_type": "text",
+                "content_text": payload.message_text, "content_json": {},
+            }, headers=headers)
+        user_message_id = _get_string(user_message, "id")
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": "running",
+                "request_payload_json": {**run_request_payload, "user_message_id": user_message_id},
+            }, headers=headers)
+
+        yield _sse("session.execute.started", {
+            "run_request_id": run_request_id, "user_message_id": user_message_id,
+            "target_type": target_type, "target_id": target_id,
+        })
+
+        output_text = ""
+        error_message: str | None = None
+        agent_run_id: str | None = None
+        team_run_id: str | None = None
+
+        if target_type == "agent":
+            agent_run = await _post_json(
+                client=client, target=agent_target, path="runs",
+                payload={
+                    "agent_id": target_id, "agent_config_id": target_config_id,
+                    "session_id": payload.session_id, "input_text": payload.message_text,
+                    "input_json": {"source": "session", "run_request_id": run_request_id},
+                }, headers=headers)
+            agent_run_id = _get_string(agent_run, "id")
+
+            stream_url = _target_url(agent_target, f"runs/{agent_run_id}/execute-stream")
+            async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
+                if not resp.is_success:
+                    error_message = await _read_stream_error(resp)
+                else:
+                    async for ev_name, ev_data in _parse_sse(resp):
+                        data = json.loads(ev_data)
+                        yield _sse(ev_name, data)
+                        if ev_name == "agent.run.delta" and isinstance(data.get("text"), str):
+                            output_text += data["text"]
+                        elif ev_name == "agent.run.completed":
+                            run_data = data.get("run", data)
+                            if not output_text and isinstance(run_data.get("output_text"), str):
+                                output_text = run_data["output_text"]
+                        elif ev_name == "agent.run.failed":
+                            error_message = data.get("error_message", "Agent execution failed")
+                            if not isinstance(error_message, str):
+                                error_message = "Agent execution failed"
+        else:
+            team_run = await _post_json(
+                client=client, target=team_target, path="runs",
+                payload={
+                    "team_id": target_id, "team_config_id": target_config_id,
+                    "session_id": payload.session_id, "input_text": payload.message_text,
+                    "input_json": {"source": "session", "run_request_id": run_request_id},
+                    "enqueue": True,
+                }, headers=headers)
+            team_run_id = _get_string(team_run, "id")
+
+            stream_url = _target_url(team_target, f"runs/{team_run_id}/execute-stream")
+            async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
+                if not resp.is_success:
+                    error_message = await _read_stream_error(resp)
+                else:
+                    async for ev_name, ev_data in _parse_sse(resp):
+                        data = json.loads(ev_data)
+                        yield _sse(ev_name, data)
+                        if ev_name == "team.run.delta" and isinstance(data.get("text"), str):
+                            output_text += data["text"]
+                        elif ev_name == "team.run.completed":
+                            run_data = data.get("run", data)
+                            if not output_text and isinstance(run_data.get("output_text"), str):
+                                output_text = run_data["output_text"]
+                        elif ev_name == "team.run.failed":
+                            error_message = data.get("error_message", "Team execution failed")
+                            if not isinstance(error_message, str):
+                                error_message = "Team execution failed"
+
+        request_status = "failed" if error_message else "completed"
+        assistant_message_id: str | None = None
+        if output_text:
+            assistant_message = await _post_json(
+                client=client, target=session_target, path="messages",
+                payload={
+                    "session_id": payload.session_id, "turn_id": run_request_id,
+                    "role": "assistant", "content_type": "text",
+                    "content_text": output_text, "content_json": {},
+                }, headers=headers)
+            assistant_message_id = _get_string(assistant_message, "id")
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload, "user_message_id": user_message_id,
+                    "assistant_message_id": assistant_message_id,
+                    "agent_run_id": agent_run_id, "team_run_id": team_run_id,
+                    "output_text": output_text, "error_message": error_message,
+                },
+            }, headers=headers)
+
+        yield _sse("session.execute.completed", {
+            "run_request_id": run_request_id, "request_status": request_status,
+            "assistant_message_id": assistant_message_id, "output_text": output_text,
+            "error_message": error_message,
+        })
+
+    except HTTPException as exc:
+        detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+        yield _sse("session.execute.failed", {"error_message": detail})
+    except Exception as exc:
+        yield _sse("session.execute.failed", {"error_message": str(exc)})
+    finally:
+        await client.aclose()
+
+
 @router.api_route(
     "/gateway/sessions",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -477,3 +868,126 @@ async def proxy_code_runner_service(
         request=request,
         target=build_proxy_targets(settings)["code-runner-service"],
         path=path)
+
+
+def _build_internal_headers(request: Request, settings: ApiGatewaySettings) -> dict[str, str]:
+    headers = build_internal_service_headers(settings)
+    authorization = request.headers.get("authorization")
+    user_id = request.headers.get("x-user-id")
+    if authorization:
+        headers["authorization"] = authorization
+    if user_id:
+        headers["x-user-id"] = user_id
+    return headers
+
+
+def httpx_client(timeout_seconds: float) -> httpx.AsyncClient:
+    return httpx.AsyncClient(timeout=timeout_seconds)
+
+
+async def _post_json(
+    *,
+    client: httpx.AsyncClient,
+    target: ProxyTarget,
+    path: str,
+    payload: dict[str, object],
+    headers: dict[str, str]) -> dict[str, object]:
+    url = _target_url(target, path)
+    try:
+        response = await client.post(url, headers=headers, json=payload)
+    except httpx.HTTPError as exc:
+        raise HTTPException(status_code=502, detail=f"{target.service_name} request failed: {exc}") from exc
+    if not response.is_success:
+        raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
+    data = response.json()
+    if not isinstance(data, dict):
+        raise HTTPException(status_code=502, detail=f"{target.service_name} returned unexpected response")
+    return data
+
+
+def _target_url(target: ProxyTarget, path: str) -> str:
+    normalized_path = path.strip("/")
+    if normalized_path:
+        return f"{target.base_url.rstrip('/')}{target.path_prefix}/{normalized_path}"
+    return f"{target.base_url.rstrip('/')}{target.path_prefix}"
+
+
+def _error_detail(response: httpx.Response) -> str:
+    try:
+        payload = response.json()
+    except ValueError:
+        return response.text or f"downstream request failed with {response.status_code}"
+    if isinstance(payload, dict):
+        detail = payload.get("detail")
+        if isinstance(detail, str):
+            return detail
+        error = payload.get("error")
+        if isinstance(error, dict):
+            message = error.get("message")
+            if isinstance(message, str):
+                return message
+    return response.text or f"downstream request failed with {response.status_code}"
+
+
+def _get_string(payload: dict[str, object], key: str) -> str:
+    value = payload.get(key)
+    if not isinstance(value, str) or not value:
+        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+    return value
+
+
+def _get_optional_string(payload: dict[str, object], key: str) -> str | None:
+    value = payload.get(key)
+    return value if isinstance(value, str) and value else None
+
+
+def _get_dict(payload: dict[str, object], key: str) -> dict[str, object]:
+    value = payload.get(key)
+    if not isinstance(value, dict):
+        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+    return value
+
+
+def _resolve_output_text(run_payload: dict[str, object]) -> str | None:
+    output_text = _get_optional_string(run_payload, "output_text")
+    if output_text:
+        return output_text
+    output_json = run_payload.get("output_json")
+    if isinstance(output_json, dict) and output_json:
+        return json.dumps(output_json, ensure_ascii=False)
+    return None
+
+
+def _sse(event: str, data: dict) -> str:
+    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+
+async def _parse_sse(response: httpx.Response):
+    current_event = "message"
+    current_data = ""
+    async for line in response.aiter_lines():
+        if line.startswith("event:"):
+            current_event = line[6:].strip()
+        elif line.startswith("data:"):
+            current_data = line[5:].strip()
+        elif line == "":
+            if current_data:
+                yield current_event, current_data
+            current_event = "message"
+            current_data = ""
+    if current_data:
+        yield current_event, current_data
+
+
+async def _read_stream_error(response: httpx.Response) -> str:
+    body = await response.aread()
+    text = body.decode(errors="replace")
+    try:
+        data = json.loads(text)
+        if isinstance(data, dict):
+            detail = data.get("detail")
+            if isinstance(detail, str):
+                return detail
+    except (ValueError, UnicodeDecodeError):
+        pass
+    return text or f"downstream error {response.status_code}"

+ 28 - 0
services/session-service/alembic/versions/20260512_0002_session_runtime_targets.py

@@ -0,0 +1,28 @@
+"""add session runtime target fields
+
+Revision ID: 20260512_0002_session
+Revises: 20260429_9001_session
+Create Date: 2026-05-12 00:00:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260512_0002_session"
+down_revision: str | None = "20260429_9001_session"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column("session", sa.Column("runtime_target_type", sa.String(length=32), nullable=True))
+    op.add_column("session", sa.Column("runtime_target_id", sa.String(length=36), nullable=True))
+    op.add_column("session", sa.Column("runtime_target_config_id", sa.String(length=36), nullable=True))
+
+
+def downgrade() -> None:
+    op.drop_column("session", "runtime_target_config_id")
+    op.drop_column("session", "runtime_target_id")
+    op.drop_column("session", "runtime_target_type")

+ 34 - 2
services/session-service/app/api/routes.py

@@ -1,5 +1,5 @@
 from core_domain import ServiceHealth
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
@@ -10,10 +10,12 @@ from app.domain.repositories import MessageRepository, RunRequestRepository, Ses
 from app.schemas.message import MessageCreateRequest, MessageListRequest, MessageResponse
 from app.schemas.run_request import (
     RunRequestCreateRequest,
+    RunRequestDetailRequest,
     RunRequestListRequest,
     RunRequestResponse,
+    RunRequestUpdateRequest,
 )
-from app.schemas.session import SessionCreateRequest, SessionListRequest, SessionResponse
+from app.schemas.session import SessionCreateRequest, SessionDetailRequest, SessionListRequest, SessionResponse
 
 router = APIRouter()
 
@@ -59,6 +61,16 @@ def list_sessions_post(
     return [SessionResponse.from_entity(item) for item in service.list_sessions(payload.app_id)]
 
 
+@router.post("/detail", response_model=SessionResponse)
+def detail_session(
+    payload: SessionDetailRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> SessionResponse:
+    entity = service.get_session(session_id=payload.session_id)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"session not found: {payload.session_id}")
+    return SessionResponse.from_entity(entity)
+
+
 @router.post("/messages", response_model=MessageResponse)
 def create_message(
     payload: MessageCreateRequest,
@@ -113,3 +125,23 @@ def list_run_requests_post(
         RunRequestResponse.from_entity(item)
         for item in service.list_run_requests(session_id=payload.session_id)
     ]
+
+
+@router.post("/run-requests/detail", response_model=RunRequestResponse)
+def get_run_request(
+    payload: RunRequestDetailRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
+    entity = service.run_request_repository.get_by_id(run_request_id=payload.run_request_id)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+    return RunRequestResponse.from_entity(entity)
+
+
+@router.post("/run-requests/update", response_model=RunRequestResponse)
+def update_run_request(
+    payload: RunRequestUpdateRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
+    entity = service.update_run_request(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+    return RunRequestResponse.from_entity(entity)

+ 14 - 2
services/session-service/app/application/services.py

@@ -2,7 +2,7 @@ from app.db.models import Message, RunRequest
 from app.db.models import Session as SessionModel
 from app.domain.repositories import MessageRepository, RunRequestRepository, SessionRepository
 from app.schemas.message import MessageCreateRequest
-from app.schemas.run_request import RunRequestCreateRequest
+from app.schemas.run_request import RunRequestCreateRequest, RunRequestUpdateRequest
 from app.schemas.session import SessionCreateRequest
 
 
@@ -21,11 +21,17 @@ class SessionApplicationService:
             app_id=payload.app_id,
             user_id=payload.user_id,
             channel_type=payload.channel_type,
-            title=payload.title)
+            title=payload.title,
+            runtime_target_type=payload.runtime_target_type,
+            runtime_target_id=payload.runtime_target_id,
+            runtime_target_config_id=payload.runtime_target_config_id)
 
     def list_sessions(self, app_id: str | None = None) -> list[SessionModel]:
         return self.session_repository.list_by_scope(app_id=app_id)
 
+    def get_session(self, *, session_id: str) -> SessionModel | None:
+        return self.session_repository.get_by_id(session_id=session_id)
+
     def create_message(self, payload: MessageCreateRequest) -> Message:
         return self.message_repository.create(
             session_id=payload.session_id,
@@ -49,3 +55,9 @@ class SessionApplicationService:
 
     def list_run_requests(self, session_id: str) -> list[RunRequest]:
         return self.run_request_repository.list_by_session(session_id=session_id)
+
+    def update_run_request(self, payload: RunRequestUpdateRequest) -> RunRequest | None:
+        return self.run_request_repository.update(
+            run_request_id=payload.run_request_id,
+            request_payload_json=payload.request_payload_json,
+            request_status=payload.request_status)

+ 3 - 0
services/session-service/app/db/models/session.py

@@ -13,6 +13,9 @@ class Session(EntityMixin, AuditMixin, Base):
     channel_type: Mapped[str] = mapped_column(String(32))
     session_status: Mapped[str] = mapped_column(String(32), default="active")
     title: Mapped[str | None] = mapped_column(String(256), nullable=True)
+    runtime_target_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    runtime_target_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    runtime_target_config_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
     started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_active_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     closed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 39 - 1
services/session-service/app/domain/repositories.py

@@ -17,12 +17,18 @@ class SessionRepository:
         app_id: str,
         user_id: str,
         channel_type: str,
-        title: str | None) -> SessionModel:
+        title: str | None,
+        runtime_target_type: str | None,
+        runtime_target_id: str | None,
+        runtime_target_config_id: str | None) -> SessionModel:
         entity = SessionModel(
             app_id=app_id,
             user_id=user_id,
             channel_type=channel_type,
             title=title,
+            runtime_target_type=runtime_target_type,
+            runtime_target_id=runtime_target_id,
+            runtime_target_config_id=runtime_target_config_id,
             started_time=datetime.utcnow(),
             last_active_time=datetime.utcnow())
         self.db.add(entity)
@@ -30,6 +36,10 @@ class SessionRepository:
         self.db.refresh(entity)
         return entity
 
+    def get_by_id(self, *, session_id: str) -> SessionModel | None:
+        stmt = select(SessionModel).where(SessionModel.id == session_id)
+        return self.db.scalars(stmt).first()
+
     def list_by_scope(self, *, app_id: str | None = None) -> list[SessionModel]:
         stmt = select(SessionModel)
         if app_id:
@@ -57,6 +67,12 @@ class MessageRepository:
             content_type=content_type,
             content_text=content_text,
             content_json=content_json)
+        session = self.db.scalars(
+            select(SessionModel).where(SessionModel.id == session_id)
+        ).first()
+        if session is not None:
+            session.last_active_time = datetime.utcnow()
+            self.db.add(session)
         self.db.add(entity)
         self.db.commit()
         self.db.refresh(entity)
@@ -103,3 +119,25 @@ class RunRequestRepository:
             .order_by(RunRequest.created_time.desc())
         )
         return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, run_request_id: str) -> RunRequest | None:
+        stmt = select(RunRequest).where(RunRequest.id == run_request_id)
+        return self.db.scalars(stmt).first()
+
+    def update(
+        self,
+        *,
+        run_request_id: str,
+        request_payload_json: dict | None = None,
+        request_status: str | None = None) -> RunRequest | None:
+        entity = self.get_by_id(run_request_id=run_request_id)
+        if entity is None:
+            return None
+        if request_payload_json is not None:
+            entity.request_payload_json = request_payload_json
+        if request_status is not None:
+            entity.request_status = request_status
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 10 - 0
services/session-service/app/schemas/run_request.py

@@ -21,6 +21,16 @@ class RunRequestListRequest(BaseModel):
     session_id: str
 
 
+class RunRequestDetailRequest(BaseModel):
+    run_request_id: str
+
+
+class RunRequestUpdateRequest(BaseModel):
+    run_request_id: str
+    request_payload_json: dict[str, JSONValue] | None = None
+    request_status: str | None = None
+
+
 class RunRequestResponse(BaseModel):
     id: str
     session_id: str

+ 10 - 0
services/session-service/app/schemas/session.py

@@ -12,12 +12,19 @@ class SessionCreateRequest(BaseModel):
     user_id: str
     channel_type: str = "web"
     title: str | None = None
+    runtime_target_type: str | None = None
+    runtime_target_id: str | None = None
+    runtime_target_config_id: str | None = None
 
 
 class SessionListRequest(BaseModel):
     app_id: str | None = None
 
 
+class SessionDetailRequest(BaseModel):
+    session_id: str
+
+
 class SessionResponse(BaseModel):
     id: str
     app_id: str
@@ -25,6 +32,9 @@ class SessionResponse(BaseModel):
     channel_type: str
     session_status: str
     title: str | None = None
+    runtime_target_type: str | None = None
+    runtime_target_id: str | None = None
+    runtime_target_config_id: str | None = None
     started_time: datetime | None = None
     last_active_time: datetime | None = None
     created_time: datetime

+ 1 - 1
web/index.html

@@ -15,7 +15,7 @@
     </script>
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Auto Platform</title>
+    <title>AgentDock</title>
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link

+ 87 - 2
web/src/api/sessions.ts

@@ -1,5 +1,7 @@
-import { apiClient } from "./client";
-import type { Message, RunRequest, Session } from "@/types";
+import { apiClient, normalizeGatewayBasePath } from "./client";
+import { useAuthStore } from "@/stores/auth";
+import { useUiStore } from "@/stores/ui";
+import type { Message, RunRequest, Session, SessionExecuteResult } from "@/types";
 
 export async function listSessions() {
   const { data } = await apiClient.post<Session[]>("/sessions/list", {});
@@ -11,6 +13,9 @@ export async function createSession(payload: {
   user_id: string;
   channel_type: string;
   title?: string | null;
+  runtime_target_type?: string | null;
+  runtime_target_id?: string | null;
+  runtime_target_config_id?: string | null;
 }) {
   const { data } = await apiClient.post<Session>("/sessions", payload);
   return data;
@@ -37,3 +42,83 @@ export async function listRunRequests(sessionId?: string) {
   const { data } = await apiClient.post<RunRequest[]>("/sessions/run-requests/list", { session_id: sessionId });
   return data;
 }
+
+export async function executeSession(payload: {
+  session_id: string;
+  message_text: string;
+  stream?: boolean;
+}) {
+  const { data } = await apiClient.post<SessionExecuteResult>("/sessions/execute", {
+    session_id: payload.session_id,
+    message_text: payload.message_text,
+    stream: payload.stream ?? false,
+  });
+  return data;
+}
+
+export function executeSessionStream(
+  payload: { session_id: string; message_text: string },
+  onEvent: (event: string, data: Record<string, unknown>) => void,
+): AbortController {
+  const controller = new AbortController();
+  const { accessToken, tokenType, userId } = useAuthStore.getState();
+  const baseUrl = normalizeGatewayBasePath(useUiStore.getState().siteSettings.gatewayBasePath);
+
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+    Accept: "text/event-stream",
+  };
+  if (accessToken) headers["Authorization"] = `${tokenType || "bearer"} ${accessToken}`;
+  if (userId) headers["x-user-id"] = userId;
+
+  fetch(`${baseUrl}/sessions/execute`, {
+    method: "POST",
+    headers,
+    body: JSON.stringify({ session_id: payload.session_id, message_text: payload.message_text, stream: true }),
+    signal: controller.signal,
+  }).then(async (response) => {
+    if (!response.ok) {
+      const text = await response.text();
+      try {
+        onEvent("session.execute.failed", { error_message: JSON.parse(text).detail || text });
+      } catch {
+        onEvent("session.execute.failed", { error_message: text || `HTTP ${response.status}` });
+      }
+      return;
+    }
+    const reader = response.body?.getReader();
+    if (!reader) {
+      onEvent("session.execute.failed", { error_message: "Streaming not supported" });
+      return;
+    }
+    const decoder = new TextDecoder();
+    let buffer = "";
+    let currentEvent = "message";
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split("\n");
+      buffer = lines.pop() || "";
+      for (const line of lines) {
+        if (line.startsWith("event:")) {
+          currentEvent = line.slice(6).trim();
+        } else if (line.startsWith("data:")) {
+          const dataStr = line.slice(5).trim();
+          try {
+            onEvent(currentEvent, JSON.parse(dataStr));
+          } catch {
+            onEvent(currentEvent, { raw: dataStr });
+          }
+          currentEvent = "message";
+        }
+      }
+    }
+  }).catch((err) => {
+    if (err.name !== "AbortError") {
+      onEvent("session.execute.failed", { error_message: err.message });
+    }
+  });
+
+  return controller;
+}

+ 1 - 1
web/src/locales/en.json

@@ -113,7 +113,7 @@
     "closeNavigation": "Close navigation"
   },
   "app": {
-    "name": "Auto Platform",
+    "name": "AgentDock",
     "loadingStudio": "Loading studio"
   },
   "demo": {

+ 1 - 1
web/src/locales/zh.json

@@ -113,7 +113,7 @@
     "closeNavigation": "关闭导航"
   },
   "app": {
-    "name": "Auto Platform",
+    "name": "AgentDock",
     "loadingStudio": "正在加载工作台"
   },
   "demo": {

+ 58 - 8
web/src/pages/sessions/SessionChatPage.tsx

@@ -1,7 +1,7 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { Info, MessageSquarePlus } from "lucide-react";
-import { createMessage, listMessages, listRunRequests } from "@/api";
+import { executeSessionStream, listMessages, listRunRequests } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { PageHeader } from "@/components/shared/PageHeader";
@@ -26,6 +26,8 @@ export function SessionChatPage() {
   const [runRequests, setRunRequests] = React.useState<RunRequest[]>([]);
   const [createOpen, setCreateOpen] = React.useState(false);
   const [contextOpen, setContextOpen] = React.useState(false);
+  const [sending, setSending] = React.useState(false);
+  const [streamingText, setStreamingText] = React.useState<string | null>(null);
 
   React.useEffect(() => {
     if (!activeSessionId && sessions.data?.[0]) setActiveSessionId(sessions.data[0].id);
@@ -47,13 +49,55 @@ export function SessionChatPage() {
     `${session.title ?? ""} ${session.channel_type} ${session.user_id}`.toLowerCase().includes(search.toLowerCase()));
   const activeSession = (sessions.data ?? []).find((session) => session.id === activeSessionId);
 
-  async function send(text: string) {
+  function send(text: string) {
     if (!activeSessionId) return;
-    await createMessage({ session_id: activeSessionId, role: "user", content_type: "text", content_text: text });
-    const [nextMessages, nextRuns] = await Promise.all([listMessages(activeSessionId), listRunRequests(activeSessionId)]);
-    setMessages(nextMessages);
-    setRunRequests(nextRuns);
-    toast.success(t("sessions.messageSent"));
+    const sessionId = activeSessionId;
+    setSending(true);
+    setStreamingText("");
+    setMessages((prev) => [...prev, {
+      id: "temp-" + Date.now(),
+      session_id: sessionId,
+      role: "user",
+      content_type: "text",
+      content_text: text,
+      created_time: new Date().toISOString(),
+    }]);
+
+    executeSessionStream(
+      { session_id: sessionId, message_text: text },
+      (event, data) => {
+        if (event === "agent.run.delta" || event === "team.run.delta") {
+          setStreamingText((prev) => (prev ?? "") + (data.text as string || ""));
+        } else if (event === "session.execute.completed") {
+          setStreamingText(null);
+          setSending(false);
+          if (data.request_status === "failed") {
+            toast.error((data.error_message as string) || t("sessions.runFailed", "Run failed"));
+          } else {
+            toast.success(t("sessions.messageSent"));
+          }
+          void Promise.all([
+            listMessages(sessionId),
+            listRunRequests(sessionId),
+            sessions.refetch(),
+          ]).then(([nextMessages, nextRuns]) => {
+            setMessages(nextMessages);
+            setRunRequests(nextRuns);
+          });
+        } else if (event === "session.execute.failed") {
+          setStreamingText(null);
+          setSending(false);
+          toast.error((data.error_message as string) || t("sessions.runFailed", "Run failed"));
+          void Promise.all([
+            listMessages(sessionId),
+            listRunRequests(sessionId),
+          ]).then(([nextMessages, nextRuns]) => {
+            setMessages(nextMessages);
+            setRunRequests(nextRuns);
+          });
+        }
+      },
+    );
   }
 
   if (sessions.loading) return <LoadingSpinner label={t("common.loading")} />;
@@ -97,7 +141,7 @@ export function SessionChatPage() {
             </Button>
           </CardHeader>
           <CardContent className="p-0">
-            <ChatPanel messages={messages} active={Boolean(activeSessionId)} onSend={(text) => void send(text)} />
+            <ChatPanel messages={messages} active={Boolean(activeSessionId)} disabled={sending} streamingText={streamingText} onSend={(text) => send(text)} />
           </CardContent>
         </Card>
       </div>
@@ -122,6 +166,7 @@ export function SessionChatPage() {
               <ContextItem label={t("sessions.application")} value={appName(apps.data ?? [], activeSession.app_id, t("sessions.unknownApplication"))} />
               <ContextItem label={t("sessions.channelType")} value={formatChannel(activeSession.channel_type)} />
               <ContextItem label={t("sessions.user")} value={activeSession.user_id} />
+              <ContextItem label={t("sessions.runtimeTarget", "Runtime target")} value={formatTarget(activeSession, t("sessions.notConfigured", "Not configured"))} />
               <ContextItem label={t("sessions.lastActive")} value={formatDateTime(activeSession.last_active_time ?? activeSession.created_time)} />
               <ContextItem label={t("sessions.messages")} value={String(messages.length)} />
               <ContextItem label={t("sessions.runActivity")} value={String(runRequests.length)} />
@@ -158,6 +203,11 @@ function appName(apps: Array<{ id: string; name: string }>, appId: string, fallb
   return apps.find((app) => app.id === appId)?.name ?? fallback;
 }
 
+function formatTarget(session: Session, fallback: string) {
+  if (!session.runtime_target_type || !session.runtime_target_id) return fallback;
+  return `${formatChannel(session.runtime_target_type)} · ${session.runtime_target_id.slice(0, 8)}`;
+}
+
 function formatChannel(value: string) {
   return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
 }

+ 26 - 3
web/src/pages/sessions/components/ChatPanel.tsx

@@ -8,10 +8,14 @@ import type { Message } from "@/types";
 export function ChatPanel({
   messages,
   active,
+  disabled = false,
+  streamingText,
   onSend,
 }: {
   messages: Message[];
   active: boolean;
+  disabled?: boolean;
+  streamingText?: string | null;
   onSend: (text: string) => void;
 }) {
   const { t } = useTranslation();
@@ -19,8 +23,11 @@ export function ChatPanel({
     <section className="flex min-h-[560px] min-w-0 flex-1 flex-col overflow-hidden bg-surface-base">
       <div className="min-w-0 flex-1 space-y-3 overflow-auto p-3 sm:p-4">
         {active ? (
-          messages.length ? (
-            messages.map((message) => <MessageBubble key={message.id} message={message} />)
+          messages.length > 0 || streamingText != null ? (
+            <>
+              {messages.map((message) => <MessageBubble key={message.id} message={message} />)}
+              {streamingText != null && <StreamingBubble text={streamingText} />}
+            </>
           ) : (
             <EmptyState icon={MessageCircle} title={t("sessions.noMessages")} description={t("sessions.sendFirstMessage")} />
           )
@@ -28,7 +35,23 @@ export function ChatPanel({
           <EmptyState icon={MessageCircle} title={t("sessions.noSessionSelected")} description={t("sessions.chooseOrCreate")} />
         )}
       </div>
-      <ChatInput disabled={!active} onSend={onSend} />
+      <ChatInput disabled={!active || disabled} onSend={onSend} />
     </section>
   );
 }
+
+function StreamingBubble({ text }: { text: string }) {
+  const { t } = useTranslation();
+  return (
+    <div className="flex min-w-0 justify-start">
+      <div className="min-w-0 max-w-[82%] rounded-md border border-border bg-surface-elevated px-3 py-2 text-sm">
+        <div className="mb-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
+          {t("sessions.roleAssistant")}
+        </div>
+        <p className="whitespace-pre-wrap break-words leading-6 [overflow-wrap:anywhere]">
+          {text}<span className="animate-pulse">|</span>
+        </p>
+      </div>
+    </div>
+  );
+}

+ 99 - 4
web/src/pages/sessions/components/CreateSessionDialog.tsx

@@ -1,13 +1,13 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
-import { createSession } from "@/api";
+import { createSession, listAgentConfigs, listAgents, listTeamConfigs, listTeams } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { useAuthStore } from "@/stores/auth";
-import type { AppResponse, Session } from "@/types";
+import type { AgentConfig, AppResponse, Session, TeamConfig, TeamDefinition } from "@/types";
 
 export function CreateSessionDialog({
   open,
@@ -25,17 +25,66 @@ export function CreateSessionDialog({
   const [appId, setAppId] = React.useState("");
   const [title, setTitle] = React.useState("");
   const [channelType, setChannelType] = React.useState("web");
+  const [targetType, setTargetType] = React.useState<"agent" | "team">("agent");
+  const [agents, setAgents] = React.useState<Array<{ id: string; name: string }>>([]);
+  const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
+  const [targetId, setTargetId] = React.useState("");
+  const [agentConfigs, setAgentConfigs] = React.useState<AgentConfig[]>([]);
+  const [teamConfigs, setTeamConfigs] = React.useState<TeamConfig[]>([]);
+  const [targetConfigId, setTargetConfigId] = React.useState("");
   const [submitting, setSubmitting] = React.useState(false);
 
   React.useEffect(() => {
     if (!appId && apps[0]) setAppId(apps[0].id);
   }, [appId, apps]);
 
+  React.useEffect(() => {
+    if (!open) return;
+    void Promise.all([listAgents(), listTeams()]).then(([nextAgents, nextTeams]) => {
+      setAgents(nextAgents.map((agent) => ({ id: agent.id, name: agent.name })));
+      setTeams(nextTeams);
+      if (!targetId) {
+        if (targetType === "agent" && nextAgents[0]) setTargetId(nextAgents[0].id);
+        if (targetType === "team" && nextTeams[0]) setTargetId(nextTeams[0].id);
+      }
+    });
+  }, [open, targetId, targetType]);
+
+  React.useEffect(() => {
+    if (!targetId) {
+      setAgentConfigs([]);
+      setTeamConfigs([]);
+      setTargetConfigId("");
+      return;
+    }
+    if (targetType === "agent") {
+      void listAgentConfigs(targetId).then((configs) => {
+        setAgentConfigs(configs);
+        setTeamConfigs([]);
+        setTargetConfigId((current) => current || configs[0]?.id || "");
+      });
+      return;
+    }
+    void listTeamConfigs(targetId).then((configs) => {
+      setTeamConfigs(configs);
+      setAgentConfigs([]);
+      setTargetConfigId((current) => current || configs[0]?.id || "");
+    });
+  }, [targetId, targetType]);
+
   async function submit(event: React.FormEvent) {
     event.preventDefault();
     setSubmitting(true);
     try {
-      const session = await createSession({ user_id: userId, app_id: appId, title: title.trim(), channel_type: channelType });
+      const session = await createSession({
+        user_id: userId,
+        app_id: appId,
+        title: title.trim(),
+        channel_type: channelType,
+        runtime_target_type: targetType,
+        runtime_target_id: targetId,
+        runtime_target_config_id: targetConfigId || null,
+      });
       toast.success(t("sessions.sessionCreated"));
       onCreated(session);
       onOpenChange(false);
@@ -72,13 +121,59 @@ export function CreateSessionDialog({
             ]}
           />
         </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeTarget", "Runtime target")}</span>
+          <Select
+            value={targetType}
+            onChange={(event) => {
+              const nextType = event.target.value as "agent" | "team";
+              setTargetType(nextType);
+              setTargetId("");
+              setTargetConfigId("");
+            }}
+            options={[
+              { value: "agent", label: t("sessions.targetAgent", "Agent") },
+              { value: "team", label: t("sessions.targetTeam", "Team") },
+            ]}
+          />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeTargetItem", "Target")}</span>
+          <Select
+            value={targetId}
+            onChange={(event) => {
+              setTargetId(event.target.value);
+              setTargetConfigId("");
+            }}
+            options={targetType === "agent"
+              ? agents.map((agent) => ({ value: agent.id, label: agent.name }))
+              : teams.map((team) => ({ value: team.id, label: team.name }))}
+          />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeConfig", "Config")}</span>
+          <Select
+            value={targetConfigId}
+            onChange={(event) => setTargetConfigId(event.target.value)}
+            options={[
+              { value: "", label: t("sessions.runtimeConfigLatest", "Latest active config") },
+              ...(targetType === "agent"
+                ? agentConfigs.map((config) => ({ value: config.id, label: formatConfigLabel(config.id, config.role || "assistant") }))
+                : teamConfigs.map((config) => ({ value: config.id, label: formatConfigLabel(config.id, config.coordination_mode || "supervisor") }))),
+            ]}
+          />
+        </label>
         <div className="flex justify-end gap-2">
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
             {t("common.cancel")}
           </Button>
-          <Button disabled={!appId || submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
+          <Button disabled={!appId || !targetId || submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
         </div>
       </form>
     </Dialog>
   );
 }
+
+function formatConfigLabel(id: string, secondary: string) {
+  return `${secondary} · ${id.slice(0, 8)}`;
+}

+ 1 - 1
web/src/stores/ui.ts

@@ -15,7 +15,7 @@ export interface SiteSettings {
 }
 
 const DEFAULT_SITE_SETTINGS: SiteSettings = {
-  workspaceName: "Auto Platform",
+  workspaceName: "AgentDock",
   defaultRoute: "/dashboard",
   density: "comfortable",
   dashboardRefreshSeconds: 30,

+ 18 - 0
web/src/types/session.ts

@@ -7,6 +7,9 @@ export interface Session {
   channel_type: string;
   session_status: string;
   title?: string | null;
+  runtime_target_type?: string | null;
+  runtime_target_id?: string | null;
+  runtime_target_config_id?: string | null;
   started_time?: string | null;
   last_active_time?: string | null;
   created_time: string;
@@ -33,3 +36,18 @@ export interface RunRequest {
   request_status: string;
   created_time: string;
 }
+
+export interface SessionExecuteResult {
+  session_id: string;
+  run_request_id: string;
+  target_type: string;
+  target_id: string;
+  target_config_id?: string | null;
+  request_status: string;
+  user_message_id: string;
+  assistant_message_id?: string | null;
+  agent_run_id?: string | null;
+  team_run_id?: string | null;
+  output_text?: string | null;
+  error_message?: string | null;
+}