diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 9936537..00ad71c 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -251,10 +251,12 @@ Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill Custom tool `ToolAgentOutput` MUST follow these rules: - Use field name `result` only. Do not introduce or keep `result_summary` compatibility aliases. +- `metadata.tool_agent_output` is the canonical source for runtime observation and history replay. +- `tool_call_args` stores input snapshot only; avoid mixing execution output into `tool_call_args`. +- `result` stores output facts only; do not repeat input parameters already present in `tool_call_args`. - `result` is for downstream agent reasoning and tool chaining, not for end-user presentation. -- Prefer compact structural facts over prose: include identifiers and execution-critical facts (`id`, `status`, `count`, `page`, operation outcome, missing required args). -- For list/read tools, include multiple candidate records when needed (at least top matches) with stable identifiers. -- For write tools, always include affected resource identifiers in `result`. +- For list/read tools, include multiple candidate records when needed (at least top matches) with stable identifiers and scheduling-critical fields. +- For write tools, include per-item operation outcomes and affected resource identifiers in `result`. - Keep `result` concise, deterministic, and machine-oriented; avoid decorative wording and UI-style formatting. ## Multi-Agent Orchestration (AgentScope Framework) diff --git a/backend/src/core/agentscope/runtime/stage_emitter.py b/backend/src/core/agentscope/runtime/stage_emitter.py index 9232d9d..fb9e7f1 100644 --- a/backend/src/core/agentscope/runtime/stage_emitter.py +++ b/backend/src/core/agentscope/runtime/stage_emitter.py @@ -109,7 +109,7 @@ class PipelineStageEmitter: "role": "tool", "stage": self._stage, "tool_name": tool_output.tool_name, - "tool_call_id": tool_output.tool_call_id, + "tool_call_id": tool_call_id, "tool_call_args": tool_output.tool_call_args, "status": tool_output.status.value, "result": tool_output.result, diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py index 38aa6c7..c52cbb2 100644 --- a/backend/src/core/agentscope/runtime/tasks.py +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import json from typing import Any, cast from uuid import UUID @@ -18,10 +19,13 @@ from core.config.settings import config from core.db.session import AsyncSessionLocal from core.logging import get_logger from core.taskiq.app import bulk_broker, critical_broker, default_broker +from schemas.messages.chat_message import ( + AgentChatMessageMetadata, + extract_user_message_attachments, +) from schemas.user import UserContext from services.base.redis import get_or_init_redis_client from services.base.supabase import supabase_service -from schemas.messages.chat_message import extract_user_message_attachments from v1.agent.dependencies import get_agent_service from v1.users.dependencies import get_user_service @@ -29,6 +33,33 @@ logger = get_logger("core.agentscope.runtime.tasks") _MAX_CONTEXT_ATTACHMENTS = 3 +def _serialize_tool_agent_output( + *, + metadata: AgentChatMessageMetadata | dict[str, object] | None, +) -> str | None: + if metadata is None: + return None + + try: + resolved_metadata = ( + metadata + if isinstance(metadata, AgentChatMessageMetadata) + else AgentChatMessageMetadata.model_validate(metadata) + ) + except Exception: + return None + + tool_agent_output = resolved_metadata.tool_agent_output + if tool_agent_output is None: + return None + + return json.dumps( + tool_agent_output.model_dump(mode="json", exclude_none=True), + ensure_ascii=True, + separators=(",", ":"), + ) + + def _load_runtime() -> type[Any]: return AgentScopeRuntimeOrchestrator @@ -53,16 +84,25 @@ async def _build_recent_context_messages( if not result: return [] - raw_messages: list[dict[str, Any]] = result.get("messages") or [] + raw_messages: list[dict[str, object]] = result.get("messages") or [] if not raw_messages: return [] converted: list[Msg] = [] for msg in raw_messages: - role = msg.get("role") - content = msg.get("content", "") - metadata = msg.get("metadata") + role_raw = msg.get("role") + role = role_raw if isinstance(role_raw, str) else "user" + content_raw = msg.get("content", "") + content: str = content_raw if isinstance(content_raw, str) else "" + metadata_raw = msg.get("metadata") + metadata: AgentChatMessageMetadata | dict[str, object] | None + if isinstance(metadata_raw, AgentChatMessageMetadata): + metadata = metadata_raw + elif isinstance(metadata_raw, dict): + metadata = metadata_raw + else: + metadata = None if role == "user" and metadata: image_blocks: list[dict[str, Any]] = [] @@ -105,6 +145,10 @@ async def _build_recent_context_messages( if role == "tool": role = "assistant" + tool_content = _serialize_tool_agent_output(metadata=metadata) + if not tool_content: + continue + content = tool_content converted.append( Msg( diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index 762530f..14f572b 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -18,6 +18,7 @@ from core.agentscope.tools.utils.calendar_ui import ( calendar_error_output, dump_tool_output, ) +from core.agentscope.tools.tool_call_context import get_current_tool_call_id from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus from v1.schedule_items.schemas import ( ScheduleItemCreateRequest, @@ -75,9 +76,28 @@ def _format_event_brief(event_items: list[dict[str, Any]], limit: int = 3) -> st event_id = str(item.get("id") or "") title = str(item.get("title") or "") start_at = str(item.get("startAt") or "") + end_at = str(item.get("endAt") or "") + timezone = str(item.get("timezone") or "") + status = str(item.get("status") or "") + description = str(item.get("description") or "") + location = str(item.get("location") or "") + reminder_minutes = item.get("reminderMinutes") + color = str(item.get("color") or "") + source_type = str(item.get("sourceType") or "") + updated_at = str(item.get("updatedAt") or "") + permission = item.get("permission") + is_owner = item.get("isOwner") if not event_id: continue - briefs.append(f"{{id={event_id},title={title},startAt={start_at}}}") + briefs.append( + "{" + f"id={event_id},title={title},startAt={start_at},endAt={end_at}," + f"timezone={timezone},status={status},description={description}," + f"location={location},reminderMinutes={reminder_minutes},color={color}," + f"sourceType={source_type},updatedAt={updated_at},permission={permission}," + f"isOwner={is_owner}" + "}" + ) return ",".join(briefs) @@ -129,18 +149,17 @@ async def calendar_read( ) total_pages = (total + page_size - 1) // page_size if total else 0 event_items = [schedule_event_to_dict(item) for item in items] - query_value = (query or "").strip() or "*" event_brief = _format_event_brief(event_items) summary = ( - f"status=success query={query_value} total={total} page={page}/" - f"{total_pages or 1} returned={len(event_items)}" + f"status=success total={total} total_pages={total_pages or 1} " + f"returned={len(event_items)} has_next={str(page < (total_pages or 1)).lower()}" ) if event_brief: summary = f"{summary} items=[{event_brief}]" return dump_tool_output( ToolAgentOutput( tool_name=tool_name, - tool_call_id=f"{tool_name}-call", + tool_call_id=get_current_tool_call_id(tool_name=tool_name), tool_call_args=tool_call_args, status=ToolStatus.SUCCESS, result=summary, @@ -359,11 +378,8 @@ async def calendar_write( success_count += 1 result_items.append( { - "index": idx, - "operation": operation, "status": "success", "eventId": str(created.id), - "message": f"日程「{created.title}」已创建", } ) success_event_ids.append(str(created.id)) @@ -397,6 +413,7 @@ async def calendar_write( color=cast(str | None, color), reminder_minutes=cast(int | None, reminder_minutes), ) + changed_fields = sorted(update_data.keys()) updated = await service.update( parsed_event_id, ScheduleItemUpdateRequest.model_validate(update_data), @@ -404,11 +421,9 @@ async def calendar_write( success_count += 1 result_items.append( { - "index": idx, - "operation": operation, "status": "success", "eventId": str(updated.id), - "message": f"日程「{updated.title}」已更新", + "changedFields": changed_fields, } ) success_event_ids.append(str(updated.id)) @@ -421,11 +436,8 @@ async def calendar_write( success_count += 1 result_items.append( { - "index": idx, - "operation": operation, "status": "success", "eventId": event_id, - "message": f"日程 {event_id} 已删除", } ) success_event_ids.append(event_id) @@ -435,8 +447,6 @@ async def calendar_write( failed_count += 1 result_items.append( { - "index": idx, - "operation": operation, "status": "failure", "eventId": event_id, "code": code, @@ -447,21 +457,30 @@ async def calendar_write( if failed_count == 0: final_status = ToolStatus.SUCCESS summary = ( - f"status=success batch={batch_size} success={success_count} " - f"failed={failed_count} ids=[{','.join(success_event_ids)}]" + f"status=success success={success_count} failed={failed_count} " + f"ids=[{','.join(success_event_ids)}]" ) elif success_count == 0: final_status = ToolStatus.FAILURE - summary = ( - f"status=failure batch={batch_size} success={success_count} " - f"failed={failed_count}" - ) + summary = f"status=failure success={success_count} failed={failed_count}" else: final_status = ToolStatus.PARTIAL summary = ( - f"status=partial batch={batch_size} success={success_count} " - f"failed={failed_count} ids=[{','.join(success_event_ids)}]" + f"status=partial success={success_count} failed={failed_count} " + f"ids=[{','.join(success_event_ids)}]" ) + compact_items = ",".join( + [ + "{" + f"status={item.get('status')}," + f"eventId={item.get('eventId')},code={item.get('code')}," + f"changedFields={item.get('changedFields')}" + "}" + for item in result_items + ] + ) + if compact_items: + summary = f"{summary} items=[{compact_items}]" error_info: ErrorInfo | None = None if final_status == ToolStatus.FAILURE: @@ -477,7 +496,11 @@ async def calendar_write( code=str( first_failure.get("code") if first_failure else "BATCH_FAILED" ), - message=str(first_failure.get("message") if first_failure else summary), + message=str( + first_failure.get("message") + if first_failure and first_failure.get("message") + else summary + ), retryable=False, details={"results": result_items}, ) @@ -489,7 +512,7 @@ async def calendar_write( return dump_tool_output( ToolAgentOutput( tool_name=tool_name, - tool_call_id=f"{tool_name}-call", + tool_call_id=get_current_tool_call_id(tool_name=tool_name), tool_call_args=tool_call_args, status=final_status, result=summary, @@ -597,11 +620,13 @@ async def calendar_share( retryable=False, ) - summary = f"status=success event_id={event_id} invited_count={len(invited)}" + summary = ( + f"status=success invited_count={len(invited)} invited=[{','.join(invited)}]" + ) return dump_tool_output( ToolAgentOutput( tool_name=tool_name, - tool_call_id=f"{tool_name}-call", + tool_call_id=get_current_tool_call_id(tool_name=tool_name), tool_call_args=tool_call_args, status=ToolStatus.SUCCESS, result=summary, diff --git a/backend/src/core/agentscope/tools/custom/user_lookup.py b/backend/src/core/agentscope/tools/custom/user_lookup.py index 82cf989..c3adfa0 100644 --- a/backend/src/core/agentscope/tools/custom/user_lookup.py +++ b/backend/src/core/agentscope/tools/custom/user_lookup.py @@ -7,6 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from agentscope.tool import ToolResponse +from core.agentscope.tools.tool_call_context import get_current_tool_call_id from core.agentscope.tools.utils import ( find_auth_email_by_user_id, list_auth_users, @@ -33,7 +34,7 @@ def _lookup_error_output( ) -> ToolResponse: output = build_error_output( tool_name="user_lookup", - tool_call_id="user_lookup-call", + tool_call_id=get_current_tool_call_id(tool_name="user_lookup"), code=code, message=message, retryable=retryable, @@ -148,7 +149,7 @@ async def user_lookup( return _dump_tool_output( ToolAgentOutput( tool_name="user_lookup", - tool_call_id="user_lookup-call", + tool_call_id=get_current_tool_call_id(tool_name="user_lookup"), tool_call_args=tool_call_args, status=ToolStatus.SUCCESS, result=summary, diff --git a/backend/src/core/agentscope/tools/tool_call_context.py b/backend/src/core/agentscope/tools/tool_call_context.py new file mode 100644 index 0000000..cfcb43f --- /dev/null +++ b/backend/src/core/agentscope/tools/tool_call_context.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from contextvars import ContextVar, Token +from uuid import uuid4 + +_CURRENT_TOOL_CALL_ID: ContextVar[str | None] = ContextVar( + "current_tool_call_id", + default=None, +) + + +def set_current_tool_call_id(tool_call_id: str | None) -> Token[str | None]: + return _CURRENT_TOOL_CALL_ID.set(tool_call_id) + + +def reset_current_tool_call_id(token: Token[str | None]) -> None: + _CURRENT_TOOL_CALL_ID.reset(token) + + +def get_current_tool_call_id(*, tool_name: str) -> str: + current = _CURRENT_TOOL_CALL_ID.get() + if isinstance(current, str) and current.strip(): + return current.strip() + return f"{tool_name}-call-{uuid4().hex}" diff --git a/backend/src/core/agentscope/tools/tool_middleware.py b/backend/src/core/agentscope/tools/tool_middleware.py index f262229..ae8dbc6 100644 --- a/backend/src/core/agentscope/tools/tool_middleware.py +++ b/backend/src/core/agentscope/tools/tool_middleware.py @@ -1,7 +1,12 @@ from __future__ import annotations from typing import Any, AsyncGenerator, Callable +from uuid import uuid4 +from core.agentscope.tools.tool_call_context import ( + reset_current_tool_call_id, + set_current_tool_call_id, +) from core.agentscope.tools.utils.tool_response_builder import ( build_error_response, ) @@ -17,6 +22,7 @@ def register_tool_middlewares( | None = None, ) -> None: effective_config = config_by_name or meta_by_name or TOOL_CONFIGS + toolkit.register_middleware(create_tool_call_context_middleware()) toolkit.register_middleware( create_approval_middleware( config_by_name=effective_config, @@ -25,12 +31,40 @@ def register_tool_middlewares( ) +def create_tool_call_context_middleware() -> Callable[..., AsyncGenerator[Any, None]]: + async def tool_call_context_middleware( + kwargs: dict[str, Any], + next_handler: Callable[..., Any], + ) -> AsyncGenerator[Any, None]: + tool_call = kwargs.get("tool_call") + tool_call_id: str | None = None + if isinstance(tool_call, dict): + raw_id = tool_call.get("id") + if isinstance(raw_id, str) and raw_id.strip(): + tool_call_id = raw_id.strip() + + token = set_current_tool_call_id(tool_call_id) + try: + async for response in await next_handler(**kwargs): + yield response + finally: + reset_current_tool_call_id(token) + + return tool_call_context_middleware + + def create_approval_middleware( *, config_by_name: dict[str, ToolConfig], approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None] | None = None, ) -> Callable[..., AsyncGenerator[Any, None]]: + def _resolve_tool_call_id(tool_call: dict[str, Any]) -> str: + raw_tool_call_id = tool_call.get("id") + if isinstance(raw_tool_call_id, str) and raw_tool_call_id.strip(): + return raw_tool_call_id.strip() + return f"tool-call-{uuid4().hex}" + async def approval_middleware( kwargs: dict[str, Any], next_handler: Callable[..., Any], @@ -74,7 +108,7 @@ def create_approval_middleware( if decision == "rejected": content = build_error_response( tool_name=tool_name, - tool_call_id=tool_call.get("id", "unknown"), + tool_call_id=_resolve_tool_call_id(tool_call), code="TOOL_REJECTED", message=f"工具 {tool_name} 的调用已被审核拒绝", retryable=False, @@ -88,7 +122,7 @@ def create_approval_middleware( pending_response = build_error_response( tool_name=tool_name, - tool_call_id=tool_call.get("id", "unknown"), + tool_call_id=_resolve_tool_call_id(tool_call), code="TOOL_PENDING_APPROVAL", message=f"工具 {tool_name} 需要审核批准", retryable=True, diff --git a/backend/src/core/agentscope/tools/utils/calendar_ui.py b/backend/src/core/agentscope/tools/utils/calendar_ui.py index 27d08a7..082b999 100644 --- a/backend/src/core/agentscope/tools/utils/calendar_ui.py +++ b/backend/src/core/agentscope/tools/utils/calendar_ui.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any from agentscope.tool import ToolResponse +from core.agentscope.tools.tool_call_context import get_current_tool_call_id from core.agentscope.tools.utils.tool_response_builder import ( build_error_output, build_tool_response, @@ -24,7 +25,7 @@ def calendar_error_output( ) -> ToolResponse: output = build_error_output( tool_name=tool_name, - tool_call_id=f"{tool_name}-call", + tool_call_id=get_current_tool_call_id(tool_name=tool_name), code=code, message=message, retryable=retryable, diff --git a/backend/tests/unit/core/agentscope/runtime/test_stage_emitter.py b/backend/tests/unit/core/agentscope/runtime/test_stage_emitter.py new file mode 100644 index 0000000..71b4c9c --- /dev/null +++ b/backend/tests/unit/core/agentscope/runtime/test_stage_emitter.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, cast + +import pytest +from agentscope.message import Msg + +from core.agentscope.runtime.stage_emitter import PipelineStageEmitter + + +@dataclass +class _FakePipeline: + events: list[dict[str, Any]] + + async def emit(self, *, session_id: str, event: dict[str, Any]) -> str: + del session_id + self.events.append(event) + return "ok" + + +@pytest.mark.asyncio +async def test_tool_result_event_uses_runtime_tool_call_id() -> None: + pipeline = _FakePipeline(events=[]) + emitter = PipelineStageEmitter( + pipeline=pipeline, + session_id="thread-1", + run_id="run-1", + stage="worker", + emit_text_events=False, + emit_tool_events=True, + ) + + tool_output = { + "tool_name": "calendar_read", + "tool_call_id": "calendar_read-call", + "tool_call_args": {"query": "demo"}, + "status": "success", + "result": "status=success total=1 returned=1", + } + msg = Msg( + name="worker", + role="assistant", + content=cast( + Any, + [ + { + "type": "tool_use", + "id": "runtime-call-123", + "name": "calendar_read", + "input": {"query": "demo"}, + }, + { + "type": "tool_result", + "id": "runtime-call-123", + "output": [{"type": "text", "text": json.dumps(tool_output)}], + }, + ], + ), + ) + + await emitter.handle_print(msg=msg, last=True) + + result_events = [e for e in pipeline.events if e.get("type") == "TOOL_CALL_RESULT"] + assert len(result_events) == 1 + assert result_events[0]["tool_call_id"] == "runtime-call-123" diff --git a/backend/tests/unit/core/agentscope/runtime/test_tasks.py b/backend/tests/unit/core/agentscope/runtime/test_tasks.py index 1477001..fef210e 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_tasks.py +++ b/backend/tests/unit/core/agentscope/runtime/test_tasks.py @@ -6,6 +6,7 @@ from uuid import uuid4 import pytest import core.agentscope.runtime.tasks as tasks_module +from schemas.agent import ToolStatus from schemas.user import UserContext, parse_profile_settings @@ -231,3 +232,88 @@ async def test_build_recent_context_messages_includes_all_user_attachments( assert content[0]["type"] == "text" assert content[1]["type"] == "image" assert content[2]["type"] == "image" + + +@pytest.mark.asyncio +async def test_build_recent_context_messages_uses_tool_metadata_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeAgentService: + async def load_agent_input_messages( + self, + *, + thread_id: str, + ) -> dict[str, object] | None: + del thread_id + return { + "messages": [ + { + "role": "tool", + "content": "legacy-content", + "metadata": { + "run_id": "run-1", + "tool_agent_output": { + "tool_name": "calendar_read", + "tool_call_id": "tool-call-1", + "tool_call_args": { + "query": "team sync", + "page": 1, + "page_size": 20, + }, + "status": ToolStatus.SUCCESS.value, + "result": "status=success total=1 returned=1", + }, + }, + } + ] + } + + monkeypatch.setattr( + tasks_module, "get_agent_service", lambda session: _FakeAgentService() + ) + + messages = await tasks_module._build_recent_context_messages( + session=object(), + thread_id=str(uuid4()), + ) + + assert len(messages) == 1 + assert messages[0].role == "assistant" + assert messages[0].content == ( + '{"tool_name":"calendar_read","tool_call_id":"tool-call-1",' + '"tool_call_args":{"query":"team sync","page":1,"page_size":20},' + '"status":"success","result":"status=success total=1 returned=1"}' + ) + + +@pytest.mark.asyncio +async def test_build_recent_context_messages_skips_tool_without_metadata_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeAgentService: + async def load_agent_input_messages( + self, + *, + thread_id: str, + ) -> dict[str, object] | None: + del thread_id + return { + "messages": [ + { + "role": "tool", + "content": "legacy-content", + "metadata": {"run_id": "run-1"}, + } + ] + } + + monkeypatch.setattr( + tasks_module, "get_agent_service", lambda session: _FakeAgentService() + ) + + messages = await tasks_module._build_recent_context_messages( + session=object(), + thread_id=str(uuid4()), + ) + + assert messages == [] diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index ff9417e..ce18c2a 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -156,6 +156,7 @@ async def test_calendar_write_create_normalizes_to_utc( assert payload["status"] == "success" assert payload["result"].startswith("status=success") + assert "items=[{status=success,eventId=" in payload["result"] assert fake_service.created_id in payload["result"] assert fake_service.created_request is not None request = fake_service.created_request @@ -207,6 +208,9 @@ async def test_calendar_read_returns_structured_result_with_ids( assert payload["status"] == "success" assert payload["result"].startswith("status=success") - assert "query=会议" in payload["result"] + assert "total=1" in payload["result"] + assert "timezone=Asia/Shanghai" in payload["result"] + assert "description=今天下午五点的会议" in payload["result"] + assert "status=" in payload["result"] assert fake_service.created_id in payload["result"] assert fake_service.list_calls == [{"page": 1, "page_size": 20, "query": "会议"}] diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 2414e4b..17e258f 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -1,210 +1,216 @@ name: social-app-prod services: - redis: - image: redis:7-alpine - container_name: social-prod-redis - restart: unless-stopped - environment: - - REDIS_PASSWORD=${SOCIAL_REDIS__PASSWORD} - command: > - sh -c 'test -n "$$REDIS_PASSWORD" && exec redis-server --appendonly yes --dir /data --requirepass "$$REDIS_PASSWORD" || (echo "REDIS_PASSWORD is required" >&2; exit 1)' - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi"] - interval: 5s - timeout: 3s - retries: 10 + redis: + image: redis:7-alpine + container_name: social-prod-redis + restart: unless-stopped + environment: + - REDIS_PASSWORD=${SOCIAL_REDIS__PASSWORD} + command: > + sh -c 'test -n "$$REDIS_PASSWORD" && exec redis-server --appendonly yes --dir /data --requirepass "$$REDIS_PASSWORD" || (echo "REDIS_PASSWORD is required" >&2; exit 1)' + volumes: + - redis_data:/data + healthcheck: + test: + [ + "CMD", + "sh", + "-c", + 'if [ -n "$$REDIS_PASSWORD" ]; then redis-cli -a "$$REDIS_PASSWORD" ping; else redis-cli ping; fi', + ] + interval: 5s + timeout: 3s + retries: 10 - litellm-config-job: - image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-litellm-config-job - restart: "no" - env_file: - - ./.env.prod - environment: - - PYTHONPATH=/app/backend/src - - PYTHONDONTWRITEBYTECODE=1 - command: > - .venv/bin/python backend/scripts/build_litellm_proxy_config.py --output /config/litellm-proxy-config.yaml - volumes: - - litellm_config:/config - depends_on: - redis: - condition: service_healthy + litellm-config-job: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-litellm-config-job + restart: "no" + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + command: > + .venv/bin/python backend/scripts/build_litellm_proxy_config.py --output /config/litellm-proxy-config.yaml + volumes: + - litellm_config:/config + depends_on: + redis: + condition: service_healthy - litellm: - image: ${SOCIAL_LITELLM_IMAGE:-ghcr.io/berriai/litellm@sha256:b959a1816fa454a14d2842242d0fa1cd0d39f96fc94d3a1f4e1de4e48e2398c6} - container_name: social-prod-litellm - restart: unless-stopped - env_file: - - ./.env.prod - command: > - --config /config/litellm-proxy-config.yaml --host ${SOCIAL_LITELLM__BIND_HOST:-0.0.0.0} --port ${SOCIAL_LITELLM__PORT:-3875} - volumes: - - litellm_config:/config:ro - depends_on: - redis: - condition: service_healthy - litellm-config-job: - condition: service_completed_successfully - healthcheck: - test: - [ - "CMD", - "python", - "-c", - "import os,sys,urllib.request;port=os.getenv('SOCIAL_LITELLM__PORT','3875');u=f'http://127.0.0.1:{port}/health';sys.exit(0 if urllib.request.urlopen(u, timeout=3).getcode() < 500 else 1)", - ] - interval: 15s - timeout: 5s - retries: 10 + litellm: + image: ghcr.io/berriai/litellm@sha256:b959a1816fa454a14d2842242d0fa1cd0d39f96fc94d3a1f4e1de4e48e2398c6 + container_name: social-prod-litellm + restart: unless-stopped + env_file: + - ./.env.prod + command: > + --config /config/litellm-proxy-config.yaml --host ${SOCIAL_LITELLM__BIND_HOST:-0.0.0.0} --port ${SOCIAL_LITELLM__PORT:-3875} + volumes: + - litellm_config:/config:ro + depends_on: + redis: + condition: service_healthy + litellm-config-job: + condition: service_completed_successfully + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import os,sys,urllib.request;port=os.getenv('SOCIAL_LITELLM__PORT','3875');u=f'http://127.0.0.1:{port}/health';sys.exit(0 if urllib.request.urlopen(u, timeout=3).getcode() < 500 else 1)", + ] + interval: 15s + timeout: 5s + retries: 10 - web: - image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-web - restart: unless-stopped - env_file: - - ./.env.prod - environment: - - PYTHONPATH=/app/backend/src - - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=web - - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 - - SOCIAL_LITELLM__HOST=litellm - - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} - command: > - sh -c '.venv/bin/uvicorn app:app --host ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${SOCIAL_WEB__PORT:-5775} --workers ${SOCIAL_WEB__WORKERS:-2} --log-level $(printf "%s" "${SOCIAL_RUNTIME__LOG_LEVEL:-info}" | tr "[:upper:]" "[:lower:]")' - ports: - - "127.0.0.1:${SOCIAL_WEB__PORT:-5775}:${SOCIAL_WEB__PORT:-5775}" - depends_on: - redis: - condition: service_healthy - litellm: - condition: service_healthy - volumes: - - ../logs:/app/logs - - ./static/releases:/app/deploy/static/releases:ro - healthcheck: - test: - [ - "CMD", - "python", - "-c", - "import os,sys,urllib.request;port=os.getenv('SOCIAL_WEB__PORT','5775');u=f'http://127.0.0.1:{port}/health';sys.exit(0 if urllib.request.urlopen(u, timeout=3).getcode() < 500 else 1)", - ] - interval: 15s - timeout: 5s - retries: 10 + web: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-web + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=web + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/uvicorn app:app --host ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${SOCIAL_WEB__PORT:-5775} --workers ${SOCIAL_WEB__WORKERS:-2} --log-level $(printf "%s" "${SOCIAL_RUNTIME__LOG_LEVEL:-info}" | tr "[:upper:]" "[:lower:]")' + ports: + - "127.0.0.1:${SOCIAL_WEB__PORT:-5775}:${SOCIAL_WEB__PORT:-5775}" + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + - ./static/releases:/app/deploy/static/releases:ro + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import os,sys,urllib.request;port=os.getenv('SOCIAL_WEB__PORT','5775');u=f'http://127.0.0.1:{port}/health';sys.exit(0 if urllib.request.urlopen(u, timeout=3).getcode() < 500 else 1)", + ] + interval: 15s + timeout: 5s + retries: 10 - worker-critical: - image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-worker-critical - restart: unless-stopped - env_file: - - ./.env.prod - environment: - - PYTHONPATH=/app/backend/src - - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=worker-critical - - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 - - SOCIAL_LITELLM__HOST=litellm - - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} - command: > - sh -c '.venv/bin/taskiq worker core.taskiq.app:critical_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}' - depends_on: - redis: - condition: service_healthy - litellm: - condition: service_healthy - volumes: - - ../logs:/app/logs - - ./static/releases:/app/deploy/static/releases:ro + worker-critical: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-worker-critical + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=worker-critical + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/taskiq worker core.taskiq.app:critical_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}' + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + - ./static/releases:/app/deploy/static/releases:ro - worker-default: - image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-worker-default - restart: unless-stopped - env_file: - - ./.env.prod - environment: - - PYTHONPATH=/app/backend/src - - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=worker-default - - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 - - SOCIAL_LITELLM__HOST=litellm - - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} - command: > - sh -c '.venv/bin/taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}' - depends_on: - redis: - condition: service_healthy - litellm: - condition: service_healthy - volumes: - - ../logs:/app/logs - - ./static/releases:/app/deploy/static/releases:ro + worker-default: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-worker-default + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=worker-default + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}' + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + - ./static/releases:/app/deploy/static/releases:ro - worker-bulk: - image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-worker-bulk - restart: unless-stopped - env_file: - - ./.env.prod - environment: - - PYTHONPATH=/app/backend/src - - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk - - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 - - SOCIAL_LITELLM__HOST=litellm - - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} - command: > - sh -c '.venv/bin/taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}' - depends_on: - redis: - condition: service_healthy - litellm: - condition: service_healthy - volumes: - - ../logs:/app/logs - - ./static/releases:/app/deploy/static/releases:ro + worker-bulk: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-worker-bulk + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}' + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + - ./static/releases:/app/deploy/static/releases:ro - init-job: - image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-init-job - restart: "no" - env_file: - - ./.env.prod - environment: - - PYTHONPATH=/app/backend/src - - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_RUNTIME__SERVICE_NAME=init-job - - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 - - SOCIAL_LITELLM__HOST=litellm - - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} - command: .venv/bin/python -m core.runtime.cli bootstrap - depends_on: - redis: - condition: service_healthy - litellm: - condition: service_healthy - volumes: - - ../logs:/app/logs - - ./static/releases:/app/deploy/static/releases:ro - profiles: - - job + init-job: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-init-job + restart: "no" + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=init-job + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: .venv/bin/python -m core.runtime.cli bootstrap + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + - ./static/releases:/app/deploy/static/releases:ro + profiles: + - job volumes: - redis_data: - litellm_config: + redis_data: + litellm_config: diff --git a/docs/protocols/agent/api-endpoints.md b/docs/protocols/agent/api-endpoints.md index 3732eb7..1aacb08 100644 --- a/docs/protocols/agent/api-endpoints.md +++ b/docs/protocols/agent/api-endpoints.md @@ -122,7 +122,7 @@ Base URL: `/api/v1/agent` } ``` -tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回。 +tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回。续接时以 `metadata.tool_agent_output` 作为主信源(`content` 为轻量摘要)。 ### 说明 diff --git a/docs/protocols/agent/sse-events.md b/docs/protocols/agent/sse-events.md index 22b5fae..fa7c39c 100644 --- a/docs/protocols/agent/sse-events.md +++ b/docs/protocols/agent/sse-events.md @@ -165,6 +165,12 @@ data: 说明:`TOOL_CALL_RESULT` 不再携带 `ui_schema`。tool 结果通过 `result` 字段提供紧凑、结构化、可执行的信息(优先包含 id/status/count 等关键事实),用于 agent 后续推理与工具编排。 +补充约束: + +- `tool_call_id` 必须与同次调用的 `TOOL_CALL_START/ARGS/END.toolCallId` 一致,并在每次工具调用中保持唯一。 +- `tool_call_args` 仅表示输入参数快照。 +- `result` 仅表示执行输出事实,不重复 `tool_call_args` 已包含的输入参数。 + ### 3.4 文本完成事件 #### `TEXT_MESSAGE_END` diff --git a/docs/protocols/ui/data-flow.md b/docs/protocols/ui/data-flow.md index e603183..4aba67f 100644 --- a/docs/protocols/ui/data-flow.md +++ b/docs/protocols/ui/data-flow.md @@ -98,7 +98,11 @@ data: - events:在 runtime 发送事件前编译,字段名为 `ui_schema` - history:在历史转换时编译,字段名为 `ui_schema` -tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 仅提供 `result`,并在持久化时写入 message `content`。 +tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 提供 `tool_call_args` + `result` 组合。 + +- `metadata.tool_agent_output` 是 tool 消息的完整信源(用于 runtime observation 与 history replay)。 +- `message.content` 保持轻量摘要(当前等于 `result`)。 +- `tool_call_args` 记录输入参数,`result` 记录输出事实,二者不做冗余重复。 ### 5.3 当前命名差异(实现现状)