diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 120ba5a..722fe48 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -160,10 +160,6 @@ class AgentRuntimeSettings(BaseModel): user_context_cache_prefix: str = "agent:user-context" user_context_cache_ttl_seconds: int = Field(default=600, ge=60, le=86400) user_context_cache_max_turns: int = Field(default=6, ge=1, le=100) - history_context_cache_prefix: str = "agent:history-context" - history_context_cache_ttl_seconds: int = Field(default=86400, ge=60, le=172800) - default_model_code: str = "" - streaming_enabled: bool = True class LlmSettings(BaseModel): diff --git a/backend/src/core/config/static/database/system_agents.yaml b/backend/src/core/config/static/database/system_agents.yaml index 58dbc96..588c36a 100644 --- a/backend/src/core/config/static/database/system_agents.yaml +++ b/backend/src/core/config/static/database/system_agents.yaml @@ -1,16 +1,27 @@ agents: - - agent_type: router + - agent_type: worker + llm_model_code: qwen3.5-35b-a3b + status: active + config: + temperature: 0.7 + max_tokens: null + timeout_seconds: 30 + context_messages: + mode: number + count: 20 + enabled_tool_groups: + - read + - write + + - agent_type: memory llm_model_code: qwen3.5-flash status: active config: temperature: 0.7 max_tokens: null timeout_seconds: 30 - - - agent_type: worker - llm_model_code: deepseek-chat - status: active - config: - temperature: 0.7 - max_tokens: null - timeout_seconds: 30 + context_messages: + mode: day + count: 2 + enabled_tool_groups: + - read diff --git a/backend/src/core/config/static/route/frontend_routes.yaml b/backend/src/core/config/static/route/frontend_routes.yaml index 6a617e3..ed8d818 100644 --- a/backend/src/core/config/static/route/frontend_routes.yaml +++ b/backend/src/core/config/static/route/frontend_routes.yaml @@ -74,11 +74,37 @@ routes: auth_required: true path_params: - id + - route_id: calendar.event_create + path: /calendar/events/new + description: Create page for one calendar event. + category: calendar + auth_required: true + query_params: + - date + - route_id: calendar.event_edit + path: /calendar/events/{id}/edit + description: Edit page for one calendar event. + category: calendar + auth_required: true + path_params: + - id + - route_id: calendar.event_share + path: /calendar/events/{id}/share + description: Share settings page for one calendar event. + category: calendar + auth_required: true + path_params: + - id - route_id: todo.list path: /todo description: Todo quadrants and backlog overview. category: todo auth_required: true + - route_id: todo.create + path: /todo/new + description: Create page for one todo item. + category: todo + auth_required: true - route_id: todo.detail path: /todo/{id} description: Detail page for one todo item. @@ -86,6 +112,13 @@ routes: auth_required: true path_params: - id + - route_id: todo.edit + path: /todo/{id}/edit + description: Dedicated subpage for editing one todo item (not an in-page modal). + category: todo + auth_required: true + path_params: + - id - route_id: settings.main path: /settings description: Settings hub page. diff --git a/backend/src/schemas/agent/__init__.py b/backend/src/schemas/agent/__init__.py index 767febc..86de19d 100644 --- a/backend/src/schemas/agent/__init__.py +++ b/backend/src/schemas/agent/__init__.py @@ -3,17 +3,11 @@ from schemas.agent.forwarded_props import ( parse_forwarded_props_client_time, ) from schemas.agent.runtime_models import ( + AgentOutput, ResultType, - RouterAgentOutput, - RouterUiDecision, RunStatus, ToolAgentOutput, ToolStatus, - UiMode, - WorkerAgentOutput, - WorkerAgentOutputLite, - WorkerAgentOutputRich, - resolve_worker_output_model, ) from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig from schemas.agent.ui_hints import ( @@ -26,23 +20,17 @@ from schemas.agent.ui_hints import ( __all__ = [ "AgentType", + "AgentOutput", "ClientTimeContext", "ResultType", - "RouterAgentOutput", - "RouterUiDecision", "RunStatus", "SystemAgentLLMConfig", "ToolAgentOutput", "ToolStatus", - "UiMode", "UiHintAction", "UiHintIntent", "UiHintSection", "UiHintStatus", "UiHintsPayload", - "WorkerAgentOutputLite", - "WorkerAgentOutputRich", - "WorkerAgentOutput", - "resolve_worker_output_model", "parse_forwarded_props_client_time", ] diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index d12913a..81d084b 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -8,22 +8,6 @@ from pydantic import BaseModel, ConfigDict, Field from schemas.agent.ui_hints import UiHintsPayload -class TaskType(str, Enum): - KNOWLEDGE = "knowledge" - RECOMMENDATION = "recommendation" - PLANNING = "planning" - SCHEDULING = "scheduling" - REMINDER_MANAGEMENT = "reminder_management" - TODO_MANAGEMENT = "todo_management" - COMMUNICATION_DRAFTING = "communication_drafting" - INFORMATION_ORGANIZATION = "information_organization" - STATUS_TRACKING = "status_tracking" - TRANSACTION_ASSIST = "transaction_assist" - ACTION_EXECUTION = "action_execution" - TROUBLESHOOTING = "troubleshooting" - UNKNOWN = "unknown" - - class ResultType(str, Enum): DIRECT_ANSWER = "direct_answer" OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation" @@ -42,59 +26,6 @@ class ResultType(str, Enum): UNKNOWN = "unknown" -class TaskTyping(BaseModel): - model_config = ConfigDict(extra="forbid") - - primary: TaskType = Field( - ..., - description=( - "Primary task category. Choose the single category that best " - "represents the core user intent." - ), - examples=["planning"], - ) - secondary: list[TaskType] = Field( - default_factory=list, - description=( - "Secondary task categories. Keep only strongly relevant supporting " - "categories, up to 3." - ), - examples=[["scheduling", "action_execution"]], - ) - - -class ResultTyping(BaseModel): - model_config = ConfigDict(extra="forbid") - - primary: ResultType = Field( - ..., - description=( - "Primary output type. It should match the execution mode and user " - "expectation; avoid unknown whenever possible." - ), - examples=["action_plan"], - ) - secondary: list[ResultType] = Field( - default_factory=list, - description=( - "Secondary output types. Use for compatible alternative response " - "shapes, up to 3." - ), - examples=[["todo_list", "summary"]], - ) - - -class ExecutionMode(str, Enum): - ONESTEP = "onestep" - TOOL_ASSISTED = "tool_assisted" - MULTISTEP = "multistep" - - -class UiMode(str, Enum): - NONE = "none" - RICH = "rich" - - class RunStatus(str, Enum): SUCCESS = "success" PARTIAL_SUCCESS = "partial_success" @@ -107,276 +38,33 @@ class ToolStatus(str, Enum): PARTIAL = "partial" -class KeyEntity(BaseModel): - model_config = ConfigDict(extra="forbid") - - name: str = Field( - ..., - description="Entity name, such as meeting/contact/location/project.", - ) - type: str = Field( - ..., - description="Entity type label, such as person/date/location/task.", - ) - value: str | None = Field( - default=None, - description="Normalized entity value. Keep null if normalization is uncertain.", - ) - - -class ConstraintItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - key: str = Field( - ..., - description="Constraint key, such as deadline/budget/channel/privacy.", - ) - value: str = Field( - ..., - description="Constraint value in concise natural language or normalized form.", - ) - required: bool = Field( - default=True, - description=( - "Whether this constraint is mandatory. True means execution cannot " - "proceed if violated." - ), - ) - - -class NormalizedTaskInput(BaseModel): - model_config = ConfigDict(extra="forbid") - - user_text: str = Field( - ..., - description="Normalized core user request text.", - examples=["Reschedule tomorrow's 9am standup to 3pm and notify attendees."], - ) - multimodal_summary: list[str] = Field( - default_factory=list, - description="Key points extracted by router from images or attachments.", - examples=[["Screenshot shows a calendar conflict at 09:00."]], - ) - - -class RouterUiDecision(BaseModel): - model_config = ConfigDict(extra="forbid") - - ui_mode: UiMode = Field( - ..., - description=( - "UI rendering mode decision for downstream worker schema selection. " - "Use 'none' when plain text response is sufficient; use 'rich' " - "when structured UI hints are beneficial." - ), - examples=["none", "rich"], - ) - ui_decision_reason: str = Field( - ..., - description=( - "Brief reason for UI mode decision, focused on user intent and " - "information complexity." - ), - examples=[ - "User asked a simple factual question; plain text is sufficient.", - "User needs actionable options and status blocks; rich UI helps scanning.", - ], - ) - - -class RouterAgentOutput(BaseModel): - model_config = ConfigDict(extra="forbid") - - normalized_task_input: NormalizedTaskInput = Field( - ..., - description=( - "Normalized task input for routing. Preserve user intent faithfully " - "without adding or dropping critical semantics." - ), - examples=[ - { - "user_text": "Reschedule tomorrow's 9am standup to 3pm and notify attendees.", - "multimodal_summary": ["Calendar screenshot indicates 09:00 conflict."], - } - ], - ) - key_entities: list[KeyEntity] = Field( - default_factory=list, - description=( - "Key entities directly relevant to task execution. Return an empty " - "list when confidence is low." - ), - examples=[ - [ - {"name": "standup", "type": "event", "value": "team-standup"}, - { - "name": "tomorrow 9am", - "type": "datetime", - "value": "2026-03-14T09:00:00+08:00", - }, - { - "name": "3pm", - "type": "datetime", - "value": "2026-03-14T15:00:00+08:00", - }, - ] - ], - ) - constraints: list[ConstraintItem] = Field( - default_factory=list, - description=( - "Execution constraints, including explicit constraints and " - "high-confidence inferred constraints." - ), - examples=[ - [ - {"key": "must_notify_attendees", "value": "true", "required": True}, - {"key": "timezone", "value": "Asia/Shanghai", "required": True}, - ] - ], - ) - task_typing: TaskTyping = Field( - ..., - description=( - "Task typing result used by downstream agents for strategy and " - "capability boundaries." - ), - examples=[{"primary": "scheduling", "secondary": ["communication_drafting"]}], - ) - execution_mode: ExecutionMode = Field( - ..., - description=( - "Recommended execution mode: onestep/tool_assisted/multistep. It " - "must be feasible under current context and capabilities." - ), - examples=["tool_assisted"], - ) - result_typing: ResultTyping = Field( - ..., - description=( - "Expected result typing used to constrain downstream output " - "structure and expression." - ), - examples=[ - { - "primary": "execution_report", - "secondary": ["summary", "options_with_recommendation"], - } - ], - ) - ui: RouterUiDecision = Field( - ..., - description=( - "Router decision on whether downstream worker should use rich UI " - "schema or lightweight text-only schema." - ), - examples=[ - { - "ui_mode": "rich", - "ui_decision_reason": "The request includes multiple actionable outcomes and benefits from structured blocks.", - } - ], - ) - - class ErrorInfo(BaseModel): model_config = ConfigDict(extra="forbid") - code: str = Field( - ..., - description="Stable error code for programmatic handling and analytics.", - ) - message: str = Field( - ..., - description="Human-readable error message for user or upstream agent.", - ) - retryable: bool = Field( - default=False, - description="Whether retrying can likely resolve this error.", - ) - details: dict[str, Any] | None = Field( - default=None, - description="Diagnostic details. Must not contain sensitive data or secrets.", - ) + code: str = Field(..., description="Stable error code for programmatic handling.") + message: str = Field(..., description="Human-readable error message.") + retryable: bool = Field(default=False) + details: dict[str, Any] | None = Field(default=None) class ToolAgentOutput(BaseModel): model_config = ConfigDict(extra="forbid") - tool_name: str = Field(..., description="Invoked tool name.") - tool_call_id: str = Field( - ..., description="Tool call identifier for this invocation." - ) - tool_call_args: dict[str, Any] | None = Field( - default=None, - description="Snapshot of tool call arguments for traceability and debugging.", - ) - status: ToolStatus = Field(..., description="Tool execution status.") - result: str = Field( - ..., - description=( - "Compact machine-oriented tool result. Keep it short but include " - "action-critical facts (ids/status/counts) for downstream agent steps." - ), - ) - error: ErrorInfo | None = Field( - default=None, description="Tool execution error details." - ) + tool_name: str + tool_call_id: str + tool_call_args: dict[str, Any] | None = None + status: ToolStatus + result: str + error: ErrorInfo | None = None -class WorkerAgentOutputLite(BaseModel): +class AgentOutput(BaseModel): model_config = ConfigDict(extra="forbid") - status: RunStatus = Field( - default=RunStatus.SUCCESS, - description="Worker execution status: success/partial_success/failed.", - examples=["success"], - ) - answer: str = Field( - ..., - description=( - "Primary user-facing response text. Lead with conclusion, then " - "include only necessary details." - ), - examples=[ - "Done. I moved the standup to 3:00 PM tomorrow and prepared attendee notifications." - ], - ) - key_points: list[str] = Field( - default_factory=list, - description="Key point summary, recommended 0-5 items, one sentence each.", - examples=[["Original slot conflicted at 09:00.", "New slot set to 15:00."]], - ) - result_type: ResultType = Field( - default=ResultType.UNKNOWN, - description="Structured result type of this response. Avoid unknown whenever possible.", - examples=["execution_report"], - ) - suggested_actions: list[str] = Field( - default_factory=list, - description="Suggested next actions, 0-3 items, actionable and relevant.", - examples=[["Review attendee RSVP status after notifications are sent."]], - ) - error: ErrorInfo | None = Field( - default=None, - description="Error information for failed or partially failed runs; null on success.", - ) - - -class WorkerAgentOutputRich(WorkerAgentOutputLite): - ui_hints: UiHintsPayload | None = Field( - default=None, - description=( - "Optional expressive UI semantic annotations. Focus on information " - "and interaction intent, not concrete visual styling instructions." - ), - ) - - -WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich - - -def resolve_worker_output_model(ui_mode: UiMode) -> type[WorkerAgentOutputLite]: - if ui_mode == UiMode.RICH: - return WorkerAgentOutputRich - return WorkerAgentOutputLite + status: RunStatus = Field(default=RunStatus.SUCCESS) + answer: str + key_points: list[str] = Field(default_factory=list) + result_type: ResultType = Field(default=ResultType.UNKNOWN) + suggested_actions: list[str] = Field(default_factory=list) + error: ErrorInfo | None = None + ui_hints: UiHintsPayload | None = None diff --git a/backend/src/schemas/agent/system_agent.py b/backend/src/schemas/agent/system_agent.py index d0eda75..6389391 100644 --- a/backend/src/schemas/agent/system_agent.py +++ b/backend/src/schemas/agent/system_agent.py @@ -2,15 +2,51 @@ from __future__ import annotations from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator + +from core.agentscope.tools.tool_config import ToolGroup class AgentType(str, Enum): - ROUTER = "router" WORKER = "worker" + MEMORY = "memory" + + +class ContextBuildStrategy(str, Enum): + DAY = "day" + NUMBER = "number" + + +class ContextMessagesConfig(BaseModel): + mode: ContextBuildStrategy = ContextBuildStrategy.NUMBER + count: int = Field(default=20, ge=1, le=200) class SystemAgentLLMConfig(BaseModel): temperature: float | None = Field(default=None, ge=0.0, le=2.0) max_tokens: int | None = Field(default=None, ge=1) timeout_seconds: float | None = Field(default=30.0, gt=0.0, le=300.0) + context_messages: ContextMessagesConfig = Field( + default_factory=ContextMessagesConfig + ) + enabled_tool_groups: list[ToolGroup] = Field(default_factory=list, max_length=8) + + @field_validator("enabled_tool_groups", mode="before") + @classmethod + def _normalize_enabled_tool_groups(cls, value: object) -> list[ToolGroup]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError("enabled_tool_groups must be a list") + normalized: list[ToolGroup] = [] + for item in value: + if isinstance(item, ToolGroup): + group = item + else: + raw_group = str(item or "").strip().lower() + if not raw_group: + continue + group = ToolGroup(raw_group) + if group not in normalized: + normalized.append(group) + return normalized diff --git a/backend/src/schemas/messages/chat_message.py b/backend/src/schemas/messages/chat_message.py index aad4317..b2ee290 100644 --- a/backend/src/schemas/messages/chat_message.py +++ b/backend/src/schemas/messages/chat_message.py @@ -6,7 +6,7 @@ from typing import Any, ClassVar from uuid import UUID from pydantic import BaseModel, ConfigDict, Field -from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputRich +from schemas.agent.runtime_models import AgentOutput from ..agent import AgentType, ToolAgentOutput @@ -24,9 +24,8 @@ class AgentChatMessageMetadata(BaseModel): run_id: str agent_type: AgentType | None = None user_message_attachments: list[UserMessageAttachment] | None = None - router_agent_output: RouterAgentOutput | None = None tool_agent_output: ToolAgentOutput | None = None - worker_agent_output: WorkerAgentOutputRich | None = None + agent_output: AgentOutput | None = None class AgentChatMessage(BaseModel): diff --git a/backend/src/v1/agent/repository.py b/backend/src/v1/agent/repository.py index 5a20e80..c1380b1 100644 --- a/backend/src/v1/agent/repository.py +++ b/backend/src/v1/agent/repository.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.agent_chat_message import AgentChatMessage, AgentChatMessageRole from models.agent_chat_session import AgentChatSession +from models.system_agents import SystemAgents from schemas.messages.chat_message import ( AgentChatMessage as AgentChatMessageSchema, AgentChatMessageMetadata, @@ -194,6 +195,45 @@ class AgentRepository: "messages": snapshot_messages, } + async def get_recent_messages_by_user_window( + self, *, session_id: str, user_message_limit: int + ) -> list[dict[str, object]]: + try: + session_uuid = UUID(session_id) + except ValueError as exc: + raise HTTPException(status_code=422, detail="Invalid session_id") from exc + + safe_user_limit = max(int(user_message_limit), 1) + message_stmt = ( + select(AgentChatMessage) + .where(AgentChatMessage.session_id == session_uuid) + .where(AgentChatMessage.deleted_at.is_(None)) + .order_by(AgentChatMessage.seq.desc()) + ) + messages_desc = (await self._session.execute(message_stmt)).scalars().all() + if not messages_desc: + return [] + + selected_desc: list[AgentChatMessage] = [] + user_count = 0 + for message in messages_desc: + selected_desc.append(message) + role = ( + message.role.value + if isinstance(message.role, AgentChatMessageRole) + else str(message.role) + ) + if role == AgentChatMessageRole.USER.value: + user_count += 1 + if user_count >= safe_user_limit: + break + + selected = list(reversed(selected_desc)) + snapshot_messages: list[dict[str, object]] = [] + for message in selected: + snapshot_messages.append(await self._to_snapshot_message(message)) + return snapshot_messages + async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: try: user_uuid = UUID(user_id) @@ -211,6 +251,23 @@ class AgentRepository: return None return str(latest_id) + async def get_system_agent_config( + self, *, agent_type: str + ) -> dict[str, object] | None: + normalized_type = agent_type.strip().lower() + if not normalized_type: + return None + stmt = select(SystemAgents).where(SystemAgents.agent_type == normalized_type) + row = (await self._session.execute(stmt)).scalar_one_or_none() + if row is None: + return None + config_payload = row.config if isinstance(row.config, dict) else {} + return { + "agent_type": normalized_type, + "status": str(row.status), + "config": config_payload, + } + async def _to_snapshot_message( self, message: AgentChatMessage ) -> dict[str, object]: diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index 9e2a321..2bba279 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -168,10 +168,18 @@ class AgentService: ) await self._repository.commit() + forwarded_props = getattr(run_input, "forwarded_props", None) + system_agent_mode = "worker" + if isinstance(forwarded_props, dict): + raw_mode = forwarded_props.get("system_agent_mode") + if isinstance(raw_mode, str) and raw_mode.strip(): + system_agent_mode = raw_mode.strip().lower() + task_id = await self._queue.enqueue( command={ "command": "run", "owner_id": str(current_user.id), + "system_agent_mode": system_agent_mode, "run_input": run_input.model_dump( mode="json", by_alias=True, exclude_none=True ), @@ -185,45 +193,6 @@ class AgentService: created=created, ) - async def load_agent_input_messages( - self, - *, - thread_id: str, - ) -> dict[str, object] | None: - """Load recent messages for runtime agent input. - - Returns messages from today and yesterday (if exists). - """ - today = await self._repository.get_history_day( - session_id=thread_id, - before=None, - ) - if not today: - return None - - yesterday = await self._repository.get_history_day( - session_id=thread_id, - before=self._parse_history_day(today.get("day")), - ) - - messages: list[dict[str, object]] = [] - if yesterday and yesterday.get("messages"): - messages.extend(yesterday["messages"]) # type: ignore - if today.get("messages"): - messages.extend(today["messages"]) # type: ignore - - return {"messages": messages} - - def _parse_history_day(self, value: object) -> date | None: - if isinstance(value, date): - return value - if isinstance(value, str): - try: - return date.fromisoformat(value) - except ValueError: - return None - return None - async def _prepare_user_message( self, *, diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index efbf228..fd14f2d 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -24,7 +24,7 @@ def convert_message_to_history( 转换规则: - role=user: 读取 metadata.user_message_attachments,转换为 attachments[] - - role=assistant: 读取 metadata.worker_agent_output.ui_hints,编译成 ui_schema + - role=assistant: 读取 metadata.agent_output.ui_hints,编译成 ui_schema """ role = message.role content = message.content @@ -91,34 +91,31 @@ def _convert_user_attachments( def _compile_worker_ui_hints( metadata: AgentChatMessageMetadata | dict[str, Any] | None, ) -> dict[str, Any] | None: - """编译 assistant 消息的 worker ui_hints""" + """编译 assistant 消息的 agent ui_hints""" if not metadata: return None if isinstance(metadata, AgentChatMessageMetadata): - worker_output = metadata.worker_agent_output + agent_output = metadata.agent_output else: - worker_output_data = metadata.get("worker_agent_output") - if not worker_output_data: + agent_output_data = metadata.get("agent_output") + if not agent_output_data: return None - if isinstance(worker_output_data, dict): - raw_ui_schema = worker_output_data.get("ui_schema") + if isinstance(agent_output_data, dict): + raw_ui_schema = agent_output_data.get("ui_schema") if isinstance(raw_ui_schema, dict): return raw_ui_schema - legacy_ui_schema = worker_output_data.get("uiSchema") - if isinstance(legacy_ui_schema, dict): - return legacy_ui_schema - from schemas.agent.runtime_models import WorkerAgentOutputRich + from schemas.agent.runtime_models import AgentOutput try: - worker_output = WorkerAgentOutputRich.model_validate(worker_output_data) + agent_output = AgentOutput.model_validate(agent_output_data) except Exception: return None - if not worker_output: + if not agent_output: return None - ui_hints = worker_output.ui_hints + ui_hints = agent_output.ui_hints if not ui_hints: return None