| @@ -39,7 +39,7 @@ from motor.coordinator.models.request import RequestType | |||
| 39 | from motor.coordinator.domain.request_manager import RequestManager | 39 | from motor.coordinator.domain.request_manager import RequestManager |
| 40 | from motor.coordinator.router.dispatch import handle_request | 40 | from motor.coordinator.router.dispatch import handle_request |
| 41 | from motor.coordinator.tracer.tracing import TracerManager | 41 | from motor.coordinator.tracer.tracing import TracerManager |
| 42 | -from motor.coordinator.domain.agent_hint import parse_manage_request | 42 | +from motor.coordinator.domain.agent_hint import agent_hint_implies_manage_request |
| 43 | 43 | ||
| 44 | logger = get_logger(__name__) | 44 | logger = get_logger(__name__) |
| 45 | 45 | ||
| @@ -49,40 +49,6 @@ def get_request_manager(request: Request) -> RequestManager: | |||
| 49 | return request.app.state.request_manager | 49 | return request.app.state.request_manager |
| 50 | 50 | ||
| 51 | 51 | ||
| 52 | -def _has_session_target_edit(body_json: dict[str, Any]) -> bool: | ||
| 53 | - """Return True if any edit in agent_hint.context_management.edits targets 'session'. | ||
| 54 | - | ||
| 55 | - An edit is considered 'session-targeted' when its `target` field is either | ||
| 56 | - explicitly 'session' or absent (the V1.1 default in agent_hint.py is | ||
| 57 | - 'session' — see _EDIT_TARGET_DEFAULT). Malformed substructures are treated | ||
| 58 | - as 'no session edit' so the original validation still triggers. | ||
| 59 | - """ | ||
| 60 | - agent_hint = body_json.get("agent_hint") | ||
| 61 | - if not isinstance(agent_hint, dict): | ||
| 62 | - return False | ||
| 63 | - context_management = agent_hint.get("context_management") | ||
| 64 | - if not isinstance(context_management, dict): | ||
| 65 | - return False | ||
| 66 | - edits = context_management.get("edits") | ||
| 67 | - if not isinstance(edits, list): | ||
| 68 | - return False | ||
| 69 | - for edit in edits: | ||
| 70 | - if isinstance(edit, dict) and edit.get("target", "session") == "session": | ||
| 71 | - return True | ||
| 72 | - return False | ||
| 73 | - | ||
| 74 | - | ||
| 75 | -def _is_manage_request(body_json: dict[str, Any]) -> bool: | ||
| 76 | - """Return True if agent_hint.context_management.manage_request is True.""" | ||
| 77 | - agent_hint = body_json.get("agent_hint") | ||
| 78 | - if not isinstance(agent_hint, dict): | ||
| 79 | - return False | ||
| 80 | - context_management = agent_hint.get("context_management") | ||
| 81 | - if not isinstance(context_management, dict): | ||
| 82 | - return False | ||
| 83 | - return parse_manage_request(context_management.get("manage_request")) | ||
| 84 | - | ||
| 85 | - | ||
| 86 | def _validate_anthropic_request(body_json: dict[str, Any], *, require_max_tokens: bool = True) -> None: | 52 | def _validate_anthropic_request(body_json: dict[str, Any], *, require_max_tokens: bool = True) -> None: |
| 87 | """Validate Anthropic-style request body. Raises HTTPException on invalid.""" | 53 | """Validate Anthropic-style request body. Raises HTTPException on invalid.""" |
| 88 | if not body_json.get("model"): | 54 | if not body_json.get("model"): |
| @@ -131,9 +97,7 @@ def _validate_openai_request(body_json: dict[str, Any], request_type: RequestTyp | |||
| 131 | status_code=status.HTTP_400_BAD_REQUEST, | 97 | status_code=status.HTTP_400_BAD_REQUEST, |
| 132 | detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array", | 98 | detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array", |
| 133 | ) | 99 | ) |
| 134 | - if len(body_json[OpenAIField.MESSAGES]) == 0 and not ( | 100 | + if len(body_json[OpenAIField.MESSAGES]) == 0 and not agent_hint_implies_manage_request(body_json.get("agent_hint")): |
| 135 | - _is_manage_request(body_json) and _has_session_target_edit(body_json) | ||
| 136 | - ): | ||
| 137 | raise HTTPException( | 101 | raise HTTPException( |
| 138 | status_code=status.HTTP_400_BAD_REQUEST, | 102 | status_code=status.HTTP_400_BAD_REQUEST, |
| 139 | detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array", | 103 | detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array", |
| @@ -11,14 +11,17 @@ | |||
| 11 | 11 | ||
| 12 | Defines the Pydantic schemas that parse and validate the ``agent_hint`` block | 12 | Defines the Pydantic schemas that parse and validate the ``agent_hint`` block |
| 13 | on incoming requests — session/parent-session identifiers, cache control, | 13 | on incoming requests — session/parent-session identifiers, cache control, |
| 14 | -context management, latency control, and priority control — and exposes the | 14 | +context management, session control, latency control, and priority control — |
| 15 | -translator that converts ``CacheControl.msg_offset`` and | 15 | +and exposes the translator that converts ``CacheControl.msg_offset`` and |
| 16 | ``ContextEdit.start/end`` from message-level indices into PagedAttention block | 16 | ``ContextEdit.start/end`` from message-level indices into PagedAttention block |
| 17 | coordinates consumed by the scheduler. | 17 | coordinates consumed by the scheduler. |
| 18 | """ | 18 | """ |
| 19 | 19 | ||
| 20 | +from enum import Enum, auto | ||
| 20 | from typing import Any | 21 | from typing import Any |
| 21 | -from pydantic import BaseModel, Field, field_validator | 22 | + |
| 23 | +from pydantic import BaseModel, Field, ValidationError, field_validator | ||
| 24 | + | ||
| 22 | from motor.common.logger import get_logger | 25 | from motor.common.logger import get_logger |
| 23 | 26 | ||
| 24 | logger = get_logger(__name__) | 27 | logger = get_logger(__name__) |
| @@ -32,6 +35,7 @@ _AGENT_HINT_KNOWN_FIELDS = frozenset( | |||
| 32 | "parent_session_id", | 35 | "parent_session_id", |
| 33 | "cache_control", | 36 | "cache_control", |
| 34 | "context_management", | 37 | "context_management", |
| 38 | + "session_control", | ||
| 35 | "latency_control", | 39 | "latency_control", |
| 36 | "priority_control", | 40 | "priority_control", |
| 37 | } | 41 | } |
| @@ -44,6 +48,13 @@ _CACHE_MSG_OFFSET_DEFAULT = None | |||
| 44 | _EDIT_TYPES_ALLOWED = frozenset({"offload", "prefetch", "evict"}) | 48 | _EDIT_TYPES_ALLOWED = frozenset({"offload", "prefetch", "evict"}) |
| 45 | _EDIT_TARGETS_ALLOWED = frozenset({"session", "messages", "tools"}) | 49 | _EDIT_TARGETS_ALLOWED = frozenset({"session", "messages", "tools"}) |
| 46 | _EDIT_TARGET_DEFAULT = "session" | 50 | _EDIT_TARGET_DEFAULT = "session" |
| 51 | +_SESSION_CONTROL_TYPES_ALLOWED = frozenset({"start", "pause", "stop", "compact", "resume"}) | ||
| 52 | +_SESSION_CONTROL_EDIT_TYPES = { | ||
| 53 | + "pause": "offload", | ||
| 54 | + "stop": "evict", | ||
| 55 | + "compact": "evict", | ||
| 56 | + "resume": "prefetch", | ||
| 57 | +} | ||
| 47 | _CACHE_SERVER_ONLY_FIELDS = frozenset({"block_offset", "intra_block_offset", "token_offset"}) | 58 | _CACHE_SERVER_ONLY_FIELDS = frozenset({"block_offset", "intra_block_offset", "token_offset"}) |
| 48 | _EDIT_SERVER_ONLY_FIELDS = frozenset( | 59 | _EDIT_SERVER_ONLY_FIELDS = frozenset( |
| 49 | { | 60 | { |
| @@ -267,6 +278,31 @@ class ContextManagement(BaseModel): | |||
| 267 | return parse_manage_request(value) | 278 | return parse_manage_request(value) |
| 268 | 279 | ||
| 269 | 280 | ||
| 281 | +class SessionControl(BaseModel): | ||
| 282 | + """Session-level lifecycle control. | ||
| 283 | + | ||
| 284 | + ``type`` is one of 'start' / 'pause' / 'stop' / 'compact' / 'resume'. | ||
| 285 | + pause / stop / compact / resume are translated into a session-targeted | ||
| 286 | + context_management manage-request by apply_session_control_autofill; | ||
| 287 | + 'start' carries no context semantics. | ||
| 288 | + """ | ||
| 289 | + | ||
| 290 | + type: str = Field(..., description="One of 'start' / 'pause' / 'stop' / 'compact' / 'resume'.") | ||
| 291 | + | ||
| 292 | + | ||
| 293 | + | ||
| 294 | + def _validate_type(cls, value: Any) -> str: | ||
| 295 | + value = str(value) | ||
| 296 | + if value not in _SESSION_CONTROL_TYPES_ALLOWED: | ||
| 297 | + logger.warning( | ||
| 298 | + "Unsupported session_control.type=%r; expected one of %s. Dropping session_control.", | ||
| 299 | + value, | ||
| 300 | + sorted(_SESSION_CONTROL_TYPES_ALLOWED), | ||
| 301 | + ) | ||
| 302 | + raise ValueError(f"unsupported session_control.type: {value!r}") | ||
| 303 | + return value | ||
| 304 | + | ||
| 305 | + | ||
| 270 | class LatencyControl(BaseModel): | 306 | class LatencyControl(BaseModel): |
| 271 | """Latency/SLO hint (design-only).""" | 307 | """Latency/SLO hint (design-only).""" |
| 272 | 308 | ||
| @@ -299,6 +335,15 @@ class AgentHintInfo(BaseModel): | |||
| 299 | context_management: ContextManagement | None = Field( | 335 | context_management: ContextManagement | None = Field( |
| 300 | default=None, description="Context management hint (design-only)." | 336 | default=None, description="Context management hint (design-only)." |
| 301 | ) | 337 | ) |
| 338 | + session_control: SessionControl | None = Field( | ||
| 339 | + default=None, | ||
| 340 | + description=( | ||
| 341 | + "Session-level lifecycle control (design-only). After " | ||
| 342 | + "apply_session_control_autofill, session_control and the injected " | ||
| 343 | + "context_management coexist as aliases of the same intent; " | ||
| 344 | + "consumers must not act on both." | ||
| 345 | + ), | ||
| 346 | + ) | ||
| 302 | latency_control: LatencyControl | None = Field(default=None, description="Latency control hint (design-only).") | 347 | latency_control: LatencyControl | None = Field(default=None, description="Latency control hint (design-only).") |
| 303 | priority_control: PriorityControl | None = Field(default=None, description="Priority control hint (design-only).") | 348 | priority_control: PriorityControl | None = Field(default=None, description="Priority control hint (design-only).") |
| 304 | raw_extra: dict | None = Field( | 349 | raw_extra: dict | None = Field( |
| @@ -499,6 +544,184 @@ def _parse_context_management( | |||
| 499 | return None | 544 | return None |
| 500 | 545 | ||
| 501 | 546 | ||
| 547 | +def _parse_session_control(data: Any) -> SessionControl | None: | ||
| 548 | + if not isinstance(data, dict): | ||
| 549 | + return None | ||
| 550 | + try: | ||
| 551 | + return SessionControl(type=data.get("type")) | ||
| 552 | + except ValidationError as exc: | ||
| 553 | + logger.warning("Failed to parse session_control: %s", exc) | ||
| 554 | + return None | ||
| 555 | + | ||
| 556 | + | ||
| 557 | +class _SessionControlStatus(Enum): | ||
| 558 | + """Classification of raw agent_hint.session_control for the shared decision path.""" | ||
| 559 | + | ||
| 560 | + ABSENT = auto() # agent_hint is not a dict or carries no session_control key | ||
| 561 | + MALFORMED = auto() # session_control is present but not a dict | ||
| 562 | + CONFLICT = auto() # coexists with explicit context_management; context_management wins | ||
| 563 | + INVALID_TYPE = auto() # type not in _SESSION_CONTROL_TYPES_ALLOWED | ||
| 564 | + START = auto() # type == "start": keep the hint, inject nothing | ||
| 565 | + ACTIONABLE = auto() # type in _SESSION_CONTROL_EDIT_TYPES: translate into a manage-request | ||
| 566 | + | ||
| 567 | + | ||
| 568 | +def _resolve_session_control( | ||
| 569 | + agent_hint_data: Any, | ||
| 570 | +) -> tuple[_SessionControlStatus, Any, str]: | ||
| 571 | + """Classify raw agent_hint.session_control in a single place. | ||
| 572 | + | ||
| 573 | + Single source of truth for both ``apply_session_control_autofill`` | ||
| 574 | + (mutating translation) and ``session_control_implies_manage_request`` | ||
| 575 | + (pure predicate), so dict checks, the context_management mutual-exclusion | ||
| 576 | + rule, and type normalization/validation cannot drift between the two | ||
| 577 | + call paths. | ||
| 578 | + | ||
| 579 | + Returns ``(status, raw_type, type_value)``: | ||
| 580 | + | ||
| 581 | + - ``raw_type`` — the original value as it appeared in the request | ||
| 582 | + (preserved for diagnostic logging under CONFLICT / INVALID_TYPE). | ||
| 583 | + - ``type_value`` — the normalized string used for white-list checks, | ||
| 584 | + routing decisions, and the INFO log of valid translations. Empty | ||
| 585 | + string when ``raw_type`` is missing/None. | ||
| 586 | + | ||
| 587 | + Mutual-exclusion rule: a raw context_management only conflicts with | ||
| 588 | + session_control when it constitutes a *valid* session-targeted manage | ||
| 589 | + request (i.e. ``_has_manage_request_session_edit`` holds). An invalid | ||
| 590 | + / empty / non-dict context_management does not block session_control; | ||
| 591 | + it will be dropped by ``_parse_context_management`` anyway, so falling | ||
| 592 | + through to the ACTIONABLE / START branches keeps the client intent | ||
| 593 | + intact instead of silently downgrading the request to plain inference. | ||
| 594 | + """ | ||
| 595 | + if not isinstance(agent_hint_data, dict) or "session_control" not in agent_hint_data: | ||
| 596 | + return _SessionControlStatus.ABSENT, None, "" | ||
| 597 | + sc_data = agent_hint_data.get("session_control") | ||
| 598 | + if not isinstance(sc_data, dict): | ||
| 599 | + return _SessionControlStatus.MALFORMED, None, "" | ||
| 600 | + if "context_management" in agent_hint_data and _has_manage_request_session_edit(agent_hint_data): | ||
| 601 | + return _SessionControlStatus.CONFLICT, sc_data.get("type"), "" | ||
| 602 | + raw_type = sc_data.get("type") | ||
| 603 | + type_value = str(raw_type) if raw_type is not None else "" | ||
| 604 | + if type_value not in _SESSION_CONTROL_TYPES_ALLOWED: | ||
| 605 | + return _SessionControlStatus.INVALID_TYPE, raw_type, "" | ||
| 606 | + return ( | ||
| 607 | + _SessionControlStatus.START if type_value == "start" else _SessionControlStatus.ACTIONABLE, | ||
| 608 | + raw_type, | ||
| 609 | + type_value, | ||
| 610 | + ) | ||
| 611 | + | ||
| 612 | + | ||
| 613 | +def apply_session_control_autofill(request_json: dict) -> None: | ||
| 614 | + """Translate agent_hint.session_control into agent_hint.context_management. | ||
| 615 | + | ||
| 616 | + Mutates ``request_json`` in place — the caller shares the same | ||
| 617 | + ``agent_hint`` sub-dict with ``req_data`` via a shallow copy, so the | ||
| 618 | + injected context_management is visible to every downstream consumer | ||
| 619 | + (ensure_minimum_messages_for_session_edits, parse_agent_hint, | ||
| 620 | + attach_block_offsets). | ||
| 621 | + | ||
| 622 | + Rules (classification itself is delegated to _resolve_session_control): | ||
| 623 | + | ||
| 624 | + - session_control and context_management are mutually exclusive; when both | ||
| 625 | + are present, context_management wins and session_control is dropped. | ||
| 626 | + - invalid / non-dict session_control is dropped. | ||
| 627 | + - pause / stop / compact / resume inject a session-targeted manage-request | ||
| 628 | + (offload / evict / evict / prefetch); 'start' only keeps the parsed hint. | ||
| 629 | + """ | ||
| 630 | + if not isinstance(request_json, dict): | ||
| 631 | + return | ||
| 632 | + agent_hint = request_json.get("agent_hint") | ||
| 633 | + status, raw_type, type_value = _resolve_session_control(agent_hint) | ||
| 634 | + if status is _SessionControlStatus.ABSENT: | ||
| 635 | + return | ||
| 636 | + | ||
| 637 | + raw_session_id = agent_hint.get("session_id") | ||
| 638 | + | ||
| 639 | + if status is _SessionControlStatus.MALFORMED: | ||
| 640 | + logger.warning( | ||
| 641 | + "agent_hint.session_control=%r is not a dict; dropping session_control. session_id=%s", | ||
| 642 | + agent_hint.get("session_control"), | ||
| 643 | + raw_session_id, | ||
| 644 | + ) | ||
| 645 | + agent_hint.pop("session_control", None) | ||
| 646 | + return | ||
| 647 | + | ||
| 648 | + if status is _SessionControlStatus.CONFLICT: | ||
| 649 | + logger.warning( | ||
| 650 | + "agent_hint contains both session_control(type=%r) and context_management; " | ||
| 651 | + "keeping context_management and dropping session_control. session_id=%s", | ||
| 652 | + raw_type, | ||
| 653 | + raw_session_id, | ||
| 654 | + ) | ||
| 655 | + agent_hint.pop("session_control", None) | ||
| 656 | + return | ||
| 657 | + | ||
| 658 | + if status is _SessionControlStatus.INVALID_TYPE: | ||
| 659 | + logger.warning( | ||
| 660 | + "Unsupported session_control.type=%r; expected one of %s. Dropping session_control. session_id=%s", | ||
| 661 | + raw_type, | ||
| 662 | + sorted(_SESSION_CONTROL_TYPES_ALLOWED), | ||
| 663 | + raw_session_id, | ||
| 664 | + ) | ||
| 665 | + agent_hint.pop("session_control", None) | ||
| 666 | + return | ||
| 667 | + | ||
| 668 | + logger.info( | ||
| 669 | + "session_control.type=%s session_id=%s (raw, resolved later by parse_agent_hint)", | ||
| 670 | + type_value, | ||
| 671 | + raw_session_id, | ||
| 672 | + ) | ||
| 673 | + | ||
| 674 | + if status is _SessionControlStatus.START: | ||
| 675 | + return | ||
| 676 | + | ||
| 677 | + agent_hint["context_management"] = { | ||
G 严重程度: 建议 问题: session_control 请求缺少 session_id 时,注入的管理请求没有目标会话,且全程无任何告警,操作被静默吞掉。 原因: 实测验证: 怎么改:
在 ACTIONABLE 分支(注入前或注入时)检测最终可解析的 session_id:若请求体与 header 均无有效 session_id,输出 warning 日志(如 ![]() ![]() | |||
| 678 | + "manage_request": True, | ||
| 679 | + "edits": [{"type": _SESSION_CONTROL_EDIT_TYPES[type_value], "target": "session"}], | ||
| 680 | + } | ||
| 681 | + | ||
| 682 | + | ||
| 683 | +def session_control_implies_manage_request(agent_hint_data: Any) -> bool: | ||
| 684 | + """Return True when a raw agent_hint session_control implies a manage-request. | ||
| 685 | + | ||
| 686 | + Only pause / stop / compact / resume imply context management; 'start' does | ||
| 687 | + not. When a raw context_management block is also present it takes | ||
| 688 | + precedence (mutual exclusion), so this returns False. | ||
| 689 | + """ | ||
| 690 | + return _resolve_session_control(agent_hint_data)[0] is _SessionControlStatus.ACTIONABLE | ||
| 691 | + | ||
| 692 | + | ||
| 693 | +def _has_manage_request_session_edit(agent_hint_data: Any) -> bool: | ||
| 694 | + """Return True when raw context_management is a manage-request with a session-targeted edit. | ||
| 695 | + | ||
| 696 | + An edit is considered 'session-targeted' when its `target` field is either | ||
| 697 | + explicitly 'session' or absent (the V1.1 default is 'session' — see | ||
| 698 | + _EDIT_TARGET_DEFAULT). Malformed substructures are treated as 'no session | ||
| 699 | + edit' so that API validation keeps rejecting such bodies. | ||
| 700 | + """ | ||
| 701 | + if not isinstance(agent_hint_data, dict): | ||
| 702 | + return False | ||
| 703 | + context_management = agent_hint_data.get("context_management") | ||
| 704 | + if not isinstance(context_management, dict): | ||
| 705 | + return False | ||
| 706 | + if not parse_manage_request(context_management.get("manage_request", False)): | ||
| 707 | + return False | ||
| 708 | + edits = context_management.get("edits") | ||
| 709 | + if not isinstance(edits, list): | ||
| 710 | + return False | ||
| 711 | + return any(isinstance(edit, dict) and edit.get("target", "session") == "session" for edit in edits) | ||
| 712 | + | ||
| 713 | + | ||
| 714 | +def agent_hint_implies_manage_request(agent_hint_data: Any) -> bool: | ||
| 715 | + """Return True when a raw agent_hint qualifies the request as a manage-request. | ||
| 716 | + | ||
| 717 | + True when either the explicit context_management path holds (manage_request | ||
| 718 | + is true and at least one edit targets 'session') or the session_control | ||
| 719 | + path holds (pause / stop / compact / resume without a conflicting | ||
| 720 | + context_management). | ||
| 721 | + """ | ||
| 722 | + return _has_manage_request_session_edit(agent_hint_data) or session_control_implies_manage_request(agent_hint_data) | ||
| 723 | + | ||
| 724 | + | ||
| 502 | def _parse_priority_control(data: dict) -> PriorityControl | None: | 725 | def _parse_priority_control(data: dict) -> PriorityControl | None: |
| 503 | priority_control = None | 726 | priority_control = None |
| 504 | if isinstance(data, dict): | 727 | if isinstance(data, dict): |
| @@ -608,6 +831,8 @@ def parse_agent_hint( | |||
| 608 | tools=tools, | 831 | tools=tools, |
| 609 | ) | 832 | ) |
| 610 | 833 | ||
| 834 | + session_control = _parse_session_control(agent_hint_data.get("session_control")) | ||
| 835 | + | ||
| 611 | latency_control = None | 836 | latency_control = None |
| 612 | lc_data = agent_hint_data.get("latency_control") | 837 | lc_data = agent_hint_data.get("latency_control") |
| 613 | if isinstance(lc_data, dict): | 838 | if isinstance(lc_data, dict): |
| @@ -628,6 +853,7 @@ def parse_agent_hint( | |||
| 628 | parent_session_id=parent_session_id, | 853 | parent_session_id=parent_session_id, |
| 629 | cache_control=cache_control, | 854 | cache_control=cache_control, |
| 630 | context_management=context_management, | 855 | context_management=context_management, |
| 856 | + session_control=session_control, | ||
| 631 | latency_control=latency_control, | 857 | latency_control=latency_control, |
| 632 | priority_control=priority_control, | 858 | priority_control=priority_control, |
| 633 | raw_extra=raw_extra, | 859 | raw_extra=raw_extra, |
| @@ -643,21 +869,7 @@ def ensure_minimum_messages_for_session_edits( | |||
| 643 | req_data: dict, | 869 | req_data: dict, |
| 644 | ) -> None: | 870 | ) -> None: |
| 645 | agent_hint_data = request_json.get("agent_hint", {}) if isinstance(request_json, dict) else {} | 871 | agent_hint_data = request_json.get("agent_hint", {}) if isinstance(request_json, dict) else {} |
| 646 | - if not isinstance(agent_hint_data, dict): | 872 | + if not _has_manage_request_session_edit(agent_hint_data): |
| 647 | - return | ||
| 648 | - cm_data = agent_hint_data.get("context_management") | ||
| 649 | - if not isinstance(cm_data, dict): | ||
| 650 | - return | ||
| 651 | - | ||
| 652 | - try: | ||
| 653 | - manage_request = parse_manage_request(cm_data.get("manage_request", False)) | ||
| 654 | - except Exception: | ||
| 655 | - manage_request = False | ||
| 656 | - if not manage_request: | ||
| 657 | - return | ||
| 658 | - | ||
| 659 | - edits_raw = cm_data.get("edits") or [] | ||
| 660 | - if not any(isinstance(e, dict) and e.get("target", "session") == "session" for e in edits_raw): | ||
| 661 | return | 873 | return |
| 662 | 874 | ||
| 663 | messages = request_json.get("messages") | 875 | messages = request_json.get("messages") |
| @@ -35,6 +35,7 @@ from motor.coordinator.models.request import RequestInfo | |||||||||||||||
| 35 | from motor.coordinator.tracer.tracing import TracerManager | 35 | from motor.coordinator.tracer.tracing import TracerManager | ||||||||||||
| 36 | from motor.coordinator.domain.request_manager import RequestManager | 36 | from motor.coordinator.domain.request_manager import RequestManager | ||||||||||||
| 37 | from motor.coordinator.domain.agent_hint import ( | 37 | from motor.coordinator.domain.agent_hint import ( | ||||||||||||
| 38 | + apply_session_control_autofill, | ||||||||||||||
| 38 | parse_agent_hint, | 39 | parse_agent_hint, | ||||||||||||
| 39 | ensure_minimum_messages_for_session_edits, | 40 | ensure_minimum_messages_for_session_edits, | ||||||||||||
| 40 | ) | 41 | ) | ||||||||||||
| @@ -367,6 +368,7 @@ async def __create_request_info( | |||||||||||||||
| 367 | req_data = request_json.copy() | 368 | req_data = request_json.copy() | ||||||||||||
| 368 | client_expects_token_ids = bool(request_json.get("return_token_ids", False)) | 369 | client_expects_token_ids = bool(request_json.get("return_token_ids", False)) | ||||||||||||
| 369 | 370 | ||||||||||||||
| 371 | + apply_session_control_autofill(request_json) | ||||||||||||||
🟠 High Priority 变更点: 受影响行为/契约: 失败模式:两类请求会带着空 建议:把 改动建议
![]() ![]() 不准确? 🟠 High Priority 变更行: 影响的行为/契约: 失效模式: 补充证据:新增单测 建议:在 autofill 之后恢复占位消息注入调用,保证空 messages 的 session_control / context_management 管理请求在进入 parse_agent_hint 与下游之前被补上 system/user 占位消息。 改动建议
![]() ![]() 不准确? | |||||||||||||||
| 370 | ensure_minimum_messages_for_session_edits(request_json, req_data) | 372 | ensure_minimum_messages_for_session_edits(request_json, req_data) | ||||||||||||
| 371 | agent_hint_info = parse_agent_hint( | 373 | agent_hint_info = parse_agent_hint( | ||||||||||||
| 372 | request_json, | 374 | request_json, | ||||||||||||
| @@ -16,9 +16,13 @@ from pydantic import ValidationError | |||
| 16 | from motor.coordinator.domain.agent_hint import ( | 16 | from motor.coordinator.domain.agent_hint import ( |
| 17 | CacheControl, | 17 | CacheControl, |
| 18 | ContextEdit, | 18 | ContextEdit, |
| 19 | + SessionControl, | ||
Anthropic 端点未覆盖:_validate_anthropic_request(inference_server.py:59-61)无条件拒绝空 messages。而 session_control 概念正来自 Anthropic Claude Agent SDK——SDK 客户端打 /v1/messages + 空 messages + session_control 会得到 400。至少应在 PR 描述中说明当前仅 OpenAI 端点支持,或补齐豁免逻辑。 ![]() ![]() | |||
| 20 | + agent_hint_implies_manage_request, | ||
| 21 | + apply_session_control_autofill, | ||
| 19 | ensure_minimum_messages_for_session_edits, | 22 | ensure_minimum_messages_for_session_edits, |
| 20 | parse_agent_hint, | 23 | parse_agent_hint, |
| 21 | parse_manage_request, | 24 | parse_manage_request, |
| 25 | + session_control_implies_manage_request, | ||
| 22 | ) | 26 | ) |
| 23 | 27 | ||
| 24 | 28 | ||
| @@ -125,6 +129,266 @@ def test_ensure_minimum_messages_does_not_change_non_management_request(): | |||
| 125 | assert req_data["messages"] == messages | 129 | assert req_data["messages"] == messages |
| 126 | 130 | ||
| 127 | 131 | ||
| 132 | +def test_session_control_requires_supported_type(): | ||
| 133 | + """Unsupported session lifecycle operations fail schema validation.""" | ||
| 134 | + with pytest.raises(ValidationError): | ||
| 135 | + SessionControl(type="restart") | ||
| 136 | + | ||
| 137 | + | ||
| 138 | + | ||
| 139 | + "sc_type, expected_edit_type", | ||
| 140 | + [ | ||
| 141 | + ("pause", "offload"), | ||
| 142 | + ("stop", "evict"), | ||
| 143 | + ("compact", "evict"), | ||
| 144 | + ("resume", "prefetch"), | ||
| 145 | + ], | ||
| 146 | +) | ||
| 147 | +def test_apply_session_control_autofill_injects_context_management(sc_type, expected_edit_type): | ||
| 148 | + """pause/stop/compact/resume translate into a session-targeted manage-request.""" | ||
| 149 | + request_json = {"agent_hint": {"session_control": {"type": sc_type}}} | ||
| 150 | + | ||
| 151 | + apply_session_control_autofill(request_json) | ||
| 152 | + | ||
| 153 | + assert request_json["agent_hint"]["context_management"] == { | ||
| 154 | + "manage_request": True, | ||
| 155 | + "edits": [{"type": expected_edit_type, "target": "session"}], | ||
| 156 | + } | ||
| 157 | + assert request_json["agent_hint"]["session_control"] == {"type": sc_type} | ||
| 158 | + | ||
| 159 | + | ||
| 160 | +def test_apply_session_control_autofill_start_injects_nothing(): | ||
| 161 | + """'start' only declares a session start; no context management is synthesized.""" | ||
| 162 | + request_json = {"agent_hint": {"session_control": {"type": "start"}}} | ||
| 163 | + | ||
| 164 | + apply_session_control_autofill(request_json) | ||
| 165 | + | ||
| 166 | + assert "context_management" not in request_json["agent_hint"] | ||
| 167 | + assert request_json["agent_hint"]["session_control"] == {"type": "start"} | ||
| 168 | + | ||
| 169 | + | ||
| 170 | +def test_apply_session_control_autofill_conflict_keeps_context_management(): | ||
| 171 | + """Mutual exclusion: an explicit context_management wins and session_control is dropped.""" | ||
| 172 | + request_json = { | ||
| 173 | + "agent_hint": { | ||
| 174 | + "session_control": {"type": "stop"}, | ||
| 175 | + "context_management": {"manage_request": True, "edits": [{"type": "offload"}]}, | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + apply_session_control_autofill(request_json) | ||
| 180 | + | ||
| 181 | + assert "session_control" not in request_json["agent_hint"] | ||
| 182 | + assert request_json["agent_hint"]["context_management"]["edits"] == [{"type": "offload"}] | ||
| 183 | + | ||
| 184 | + | ||
| 185 | + | ||
| 186 | + "cm_value", | ||
| 187 | + [ | ||
| 188 | + {}, | ||
| 189 | + None, | ||
| 190 | + {"manage_request": True}, # no usable edits -> _parse_context_management returns None | ||
| 191 | + {"manage_request": False}, # no edits -> _parse_context_management returns None | ||
| 192 | + {"edits": [{"type": "offload"}]}, # default target = "session" but manage_request False | ||
| 193 | + "garbage", | ||
| 194 | + 42, | ||
| 195 | + ], | ||
| 196 | +) | ||
| 197 | +def test_apply_session_control_autofill_invalid_cm_preserves_session_control(cm_value): | ||
| 198 | + """Invalid / empty / non-dict context_management must NOT block session_control autofill. | ||
| 199 | + | ||
| 200 | + Regression: previously, presence of the ``context_management`` key alone | ||
| 201 | + triggered the CONFLICT branch and dropped ``session_control``, leaving a | ||
| 202 | + non-empty ``messages`` request to be executed as plain inference while the | ||
| 203 | + client intent (e.g. ``stop``) was silently ignored. | ||
| 204 | + """ | ||
| 205 | + request_json = { | ||
| 206 | + "agent_hint": { | ||
| 207 | + "session_control": {"type": "stop"}, | ||
| 208 | + "context_management": cm_value, | ||
| 209 | + } | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + apply_session_control_autofill(request_json) | ||
| 213 | + | ||
| 214 | + # session_control must be honored: autofill injected a session-targeted evict edit | ||
| 215 | + assert request_json["agent_hint"]["context_management"] == { | ||
| 216 | + "manage_request": True, | ||
| 217 | + "edits": [{"type": "evict", "target": "session"}], | ||
| 218 | + } | ||
| 219 | + # the original session_control hint is preserved (consistent with the | ||
| 220 | + # ACTIONABLE branch for a session_control-only request) | ||
| 221 | + assert request_json["agent_hint"]["session_control"] == {"type": "stop"} | ||
| 222 | + | ||
| 223 | + | ||
| 224 | +def test_apply_session_control_autofill_drops_invalid_type(): | ||
| 225 | + """Unsupported types are dropped instead of being executed.""" | ||
| 226 | + request_json = {"agent_hint": {"session_control": {"type": "restart"}}} | ||
| 227 | + | ||
| 228 | + apply_session_control_autofill(request_json) | ||
| 229 | + | ||
| 230 | + assert "session_control" not in request_json["agent_hint"] | ||
| 231 | + assert "context_management" not in request_json["agent_hint"] | ||
| 232 | + | ||
| 233 | + | ||
| 234 | + | ||
| 235 | + "sc_data", | ||
| 236 | + [ | ||
| 237 | + {"type": None}, | ||
| 238 | + {"type": 42}, | ||
| 239 | + {}, | ||
| 240 | + ], | ||
| 241 | +) | ||
| 242 | +def test_apply_session_control_autofill_drops_malformed_type(sc_data): | ||
| 243 | + """Missing / None / non-string type degrades to a dropped hint, never a crash.""" | ||
| 244 | + request_json = {"agent_hint": {"session_control": sc_data}} | ||
| 245 | + | ||
| 246 | + apply_session_control_autofill(request_json) | ||
| 247 | + | ||
| 248 | + assert "session_control" not in request_json["agent_hint"] | ||
| 249 | + assert "context_management" not in request_json["agent_hint"] | ||
| 250 | + | ||
| 251 | + | ||
| 252 | +def test_apply_session_control_autofill_logs_type(caplog): | ||
| 253 | + """Every valid session_control request must log the resolved type and raw session_id.""" | ||
| 254 | + request_json = { | ||
| 255 | + "agent_hint": { | ||
| 256 | + "session_id": "s-001", | ||
| 257 | + "session_control": {"type": "pause"}, | ||
| 258 | + } | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + with caplog.at_level("INFO", logger="motor.coordinator.domain.agent_hint"): | ||
| 262 | + apply_session_control_autofill(request_json) | ||
| 263 | + | ||
| 264 | + assert "session_control.type=pause" in caplog.text | ||
| 265 | + assert "session_id=s-001" in caplog.text | ||
| 266 | + | ||
| 267 | + | ||
| 268 | +def test_apply_session_control_autofill_logs_type_without_session_id(caplog): | ||
| 269 | + """Missing session_id is rendered as None; do not crash and keep the field name.""" | ||
| 270 | + request_json = {"agent_hint": {"session_control": {"type": "pause"}}} | ||
| 271 | + | ||
| 272 | + with caplog.at_level("INFO", logger="motor.coordinator.domain.agent_hint"): | ||
| 273 | + apply_session_control_autofill(request_json) | ||
| 274 | + | ||
| 275 | + assert "session_control.type=pause" in caplog.text | ||
| 276 | + assert "session_id=None" in caplog.text | ||
| 277 | + | ||
| 278 | + | ||
| 279 | +def test_apply_session_control_autofill_ignores_missing_or_malformed(): | ||
| 280 | + """Requests without a dict session_control are left untouched.""" | ||
| 281 | + no_agent_hint = {"messages": []} | ||
| 282 | + apply_session_control_autofill(no_agent_hint) | ||
| 283 | + assert no_agent_hint == {"messages": []} | ||
| 284 | + | ||
| 285 | + non_dict = {"agent_hint": {"session_control": "pause"}} | ||
| 286 | + apply_session_control_autofill(non_dict) | ||
| 287 | + assert "session_control" not in non_dict["agent_hint"] | ||
| 288 | + assert "context_management" not in non_dict["agent_hint"] | ||
| 289 | + | ||
| 290 | + | ||
| 291 | + | ||
| 292 | + "agent_hint, expected", | ||
| 293 | + [ | ||
| 294 | + ({"session_control": {"type": "pause"}}, True), | ||
| 295 | + ({"session_control": {"type": "stop"}}, True), | ||
| 296 | + ({"session_control": {"type": "compact"}}, True), | ||
| 297 | + ({"session_control": {"type": "resume"}}, True), | ||
| 298 | + ({"session_control": {"type": "start"}}, False), | ||
| 299 | + ({"session_control": {"type": "restart"}}, False), | ||
| 300 | + ({"session_control": {"type": None}}, False), | ||
| 301 | + ({"session_control": {"type": 42}}, False), | ||
| 302 | + ({"session_control": {}}, False), | ||
| 303 | + ({"session_control": "pause"}, False), | ||
| 304 | + ({}, False), | ||
| 305 | + # raw context_management without a session-targeted edit does NOT block session_control | ||
| 306 | + # (the cm would be dropped downstream anyway); session_control must still imply a manage-request. | ||
| 307 | + ({"session_control": {"type": "stop"}, "context_management": {"manage_request": True}}, True), | ||
| 308 | + ({"session_control": {"type": "stop"}, "context_management": {}}, True), | ||
| 309 | + ({"session_control": {"type": "stop"}, "context_management": None}, True), | ||
| 310 | + ({"session_control": {"type": "stop"}, "context_management": "garbage"}, True), | ||
| 311 | + # a valid session-targeted manage request still wins via mutual exclusion | ||
| 312 | + ( | ||
| 313 | + { | ||
| 314 | + "session_control": {"type": "stop"}, | ||
| 315 | + "context_management": {"manage_request": True, "edits": [{"type": "offload"}]}, | ||
| 316 | + }, | ||
| 317 | + False, | ||
| 318 | + ), | ||
| 319 | + ], | ||
| 320 | +) | ||
| 321 | +def test_session_control_implies_manage_request(agent_hint, expected): | ||
| 322 | + """Only pause/stop/compact/resume (without a valid raw context_management) imply a manage-request.""" | ||
| 323 | + assert session_control_implies_manage_request(agent_hint) is expected | ||
| 324 | + | ||
| 325 | + | ||
| 326 | + | ||
| 327 | + "agent_hint, expected", | ||
| 328 | + [ | ||
| 329 | + # explicit context_management path | ||
| 330 | + ({"context_management": {"manage_request": True, "edits": [{"type": "evict", "target": "session"}]}}, True), | ||
| 331 | + ({"context_management": {"manage_request": True, "edits": [{"type": "evict"}]}}, True), | ||
| 332 | + ({"context_management": {"manage_request": True, "edits": [{"type": "evict", "target": "messages"}]}}, False), | ||
| 333 | + ({"context_management": {"manage_request": False, "edits": [{"type": "evict", "target": "session"}]}}, False), | ||
| 334 | + ({"context_management": {"manage_request": True}}, False), | ||
| 335 | + # session_control path | ||
| 336 | + ({"session_control": {"type": "pause"}}, True), | ||
| 337 | + ({"session_control": {"type": "stop"}}, True), | ||
| 338 | + ({"session_control": {"type": "start"}}, False), | ||
| 339 | + ({"session_control": {"type": "restart"}}, False), | ||
| 340 | + # invalid / empty raw cm is NOT a conflict; session_control still implies a manage-request | ||
| 341 | + ( | ||
| 342 | + { | ||
| 343 | + "session_control": {"type": "stop"}, | ||
| 344 | + "context_management": {"manage_request": True, "edits": []}, | ||
| 345 | + }, | ||
| 346 | + True, | ||
| 347 | + ), | ||
| 348 | + ({"session_control": "pause"}, False), | ||
| 349 | + ({}, False), | ||
| 350 | + ], | ||
| 351 | +) | ||
| 352 | +def test_agent_hint_implies_manage_request(agent_hint, expected): | ||
| 353 | + """The single predicate covers both the explicit cm path and the session_control path.""" | ||
| 354 | + assert agent_hint_implies_manage_request(agent_hint) is expected | ||
| 355 | + | ||
| 356 | + | ||
| 357 | +def test_parse_agent_hint_session_control_after_autofill(): | ||
| 358 | + """Dispatch order (autofill then parse) populates both session_control and context_management.""" | ||
| 359 | + request_json = {"agent_hint": {"session_id": "s", "session_control": {"type": "resume"}}} | ||
| 360 | + | ||
| 361 | + apply_session_control_autofill(request_json) | ||
| 362 | + hint = parse_agent_hint(request_json) | ||
| 363 | + | ||
| 364 | + assert hint.session_control is not None | ||
| 365 | + assert hint.session_control.type == "resume" | ||
| 366 | + assert hint.context_management is not None | ||
| 367 | + assert hint.context_management.manage_request is True | ||
| 368 | + assert [(edit.type, edit.target) for edit in hint.context_management.edits] == [("prefetch", "session")] | ||
| 369 | + assert "session_control" not in hint.raw_extra | ||
| 370 | + | ||
| 371 | + | ||
| 372 | +def test_parse_agent_hint_drops_invalid_session_control(): | ||
| 373 | + """parse_agent_hint alone drops session_control with an unsupported type.""" | ||
| 374 | + hint = parse_agent_hint({"agent_hint": {"session_control": {"type": "nope"}}}) | ||
| 375 | + | ||
| 376 | + assert hint.session_control is None | ||
| 377 | + assert hint.context_management is None | ||
| 378 | + | ||
| 379 | + | ||
| 380 | +def test_session_control_autofill_then_minimum_messages_injection(): | ||
| 381 | + """Autofill must run before message injection so empty session-control requests get placeholders.""" | ||
| 382 | + request_json = {"agent_hint": {"session_control": {"type": "stop"}}} | ||
| 383 | + req_data = request_json.copy() | ||
| 384 | + | ||
| 385 | + apply_session_control_autofill(request_json) | ||
| 386 | + ensure_minimum_messages_for_session_edits(request_json, req_data) | ||
| 387 | + | ||
| 388 | + assert len(request_json["messages"]) == 2 | ||
| 389 | + assert [message["role"] for message in request_json["messages"]] == ["system", "user"] | ||
| 390 | + | ||
| 391 | + | ||
| 128 | def test_parse_agent_hint_resolves_lowercase_headers_from_real_request(): | 392 | def test_parse_agent_hint_resolves_lowercase_headers_from_real_request(): |
| 129 | """Regression: Starlette normalizes header keys to lowercase; parse_agent_hint | 393 | """Regression: Starlette normalizes header keys to lowercase; parse_agent_hint |
| 130 | must read lowercase keys (matches the real Request.headers path used in dispatch). | 394 | must read lowercase keys (matches the real Request.headers path used in dispatch). |
| @@ -1222,8 +1222,73 @@ class TestCoordinatorServerAdvanced: | |||
| 1222 | json=data, | 1222 | json=data, |
| 1223 | headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | 1223 | headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, |
| 1224 | ) | 1224 | ) |
| 1225 | - assert response.status_code != 400, ( | 1225 | + assert response.status_code == 200, ( |
| 1226 | - f"Empty messages with manage=true + target=session must bypass the non-empty check, got {response.status_code}: {response.text}" | 1226 | + f"Empty messages with manage=true + target=session must bypass the non-empty check " |
| 1227 | + f"and reach the mocked handle_request (200), got {response.status_code}: {response.text}" | ||
| 1228 | + ) | ||
| 1229 | + | ||
| 1230 | + def test_validate_openai_request_empty_messages_session_control_allowed(self): | ||
| 1231 | + """session_control pause/stop/compact/resume must bypass the empty-messages check.""" | ||
| 1232 | + data = { | ||
| 1233 | + "model": "gpt-3.5-turbo", | ||
| 1234 | + "messages": [], | ||
| 1235 | + "agent_hint": {"session_control": {"type": "stop"}}, | ||
| 1236 | + } | ||
| 1237 | + inference_client = TestClient(self.coordinator_server.inference_app) | ||
| 1238 | + response = inference_client.post( | ||
| 1239 | + "/v1/chat/completions", | ||
| 1240 | + json=data, | ||
| 1241 | + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | ||
| 1242 | + ) | ||
| 1243 | + assert response.status_code == 200, ( | ||
| 1244 | + f"Empty messages with session_control must bypass the non-empty check " | ||
| 1245 | + f"and reach the mocked handle_request (200), got {response.status_code}: {response.text}" | ||
| 1246 | + ) | ||
| 1247 | + | ||
| 1248 | + def test_validate_openai_request_empty_messages_session_control_start_rejected(self): | ||
| 1249 | + """'start' carries no context semantics, so empty messages must still be rejected.""" | ||
| 1250 | + data = { | ||
| 1251 | + "model": "gpt-3.5-turbo", | ||
| 1252 | + "messages": [], | ||
| 1253 | + "agent_hint": {"session_control": {"type": "start"}}, | ||
| 1254 | + } | ||
| 1255 | + inference_client = TestClient(self.coordinator_server.inference_app) | ||
| 1256 | + response = inference_client.post( | ||
| 1257 | + "/v1/chat/completions", | ||
| 1258 | + json=data, | ||
| 1259 | + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | ||
| 1260 | + ) | ||
| 1261 | + assert response.status_code == 400, ( | ||
| 1262 | + f"Expected 400 for empty messages with session_control=start, got: {response.status_code}" | ||
| 1263 | + ) | ||
| 1264 | + | ||
| 1265 | + def test_validate_openai_request_empty_messages_session_control_invalid_cm_allowed(self): | ||
| 1266 | + """session_control=stop with an INVALID raw context_management (manage_request=False) | ||
| 1267 | + must still bypass the empty-messages check. | ||
| 1268 | + | ||
| 1269 | + Regression: previously the mere presence of ``context_management`` triggered the | ||
| 1270 | + mutual-exclusion branch in ``_resolve_session_control`` and dropped session_control, | ||
| 1271 | + so a request like ``session_control=stop`` + ``context_management={manage_request: False}`` | ||
| 1272 | + fell through to plain inference and was rejected for empty messages — silently | ||
| 1273 | + downgrading the client's ``stop`` intent. | ||
| 1274 | + """ | ||
| 1275 | + data = { | ||
| 1276 | + "model": "gpt-3.5-turbo", | ||
| 1277 | + "messages": [], | ||
| 1278 | + "agent_hint": { | ||
| 1279 | + "session_control": {"type": "stop"}, | ||
| 1280 | + "context_management": {"manage_request": False, "edits": [{"type": "evict", "target": "session"}]}, | ||
| 1281 | + }, | ||
| 1282 | + } | ||
| 1283 | + inference_client = TestClient(self.coordinator_server.inference_app) | ||
| 1284 | + response = inference_client.post( | ||
| 1285 | + "/v1/chat/completions", | ||
| 1286 | + json=data, | ||
| 1287 | + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | ||
| 1288 | + ) | ||
| 1289 | + assert response.status_code == 200, ( | ||
| 1290 | + f"Invalid raw context_management must NOT block session_control=stop; " | ||
| 1291 | + f"expected 200 (reaches handle_request), got {response.status_code}: {response.text}" | ||
| 1227 | ) | 1292 | ) |
| 1228 | 1293 | ||
| 1229 | def test_validate_openai_request_invalid_message_format(self): | 1294 | def test_validate_openai_request_invalid_message_format(self): |


block_offset_translator.py:1049 的 attach_block_offsets 在 hint.session_id 为 None 时直接 return;而 pause/stop/compact/resume 语义上必须有目标 session。缺 session_id 的请求会:通过校验(空 messages 放行)→ autofill 注入 cm → ensure_minimum 注入两条占位 messages → 被当作普通推理执行并返回无意义补全,无任何告警(只有一条 session_id=None 的 INFO)。显式 context_management 路径同样有此问题(预存在),但 session_control 把入口门槛降到了“只带一个 type 字段”,更易踩中。建议:ACTIONABLE 状态强制要求 session_id,缺失时 400(或至少 WARNING + 拒绝下发)。