已合并
[Featture]Add session_control to AgentHint #737
[Featture]Add session_control to AgentHint #737
已合并
zengwei创建于 8月14日
5 个文件变更+565-58
@@ -39,7 +39,7 @@ from motor.coordinator.models.request import RequestType
39from motor.coordinator.domain.request_manager import RequestManager39from motor.coordinator.domain.request_manager import RequestManager
40from motor.coordinator.router.dispatch import handle_request40from motor.coordinator.router.dispatch import handle_request
41from motor.coordinator.tracer.tracing import TracerManager41from motor.coordinator.tracer.tracing import TracerManager
42-from motor.coordinator.domain.agent_hint import parse_manage_request42+from motor.coordinator.domain.agent_hint import agent_hint_implies_manage_request
43 43 
44logger = get_logger(__name__)44logger = get_logger(__name__)
45 45 
@@ -49,40 +49,6 @@ def get_request_manager(request: Request) -> RequestManager:
49 return request.app.state.request_manager49 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- 
86def _validate_anthropic_request(body_json: dict[str, Any], *, require_max_tokens: bool = True) -> None:52def _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 
12Defines the Pydantic schemas that parse and validate the ``agent_hint`` block12Defines the Pydantic schemas that parse and validate the ``agent_hint`` block
13on incoming requests — session/parent-session identifiers, cache control,13on incoming requests — session/parent-session identifiers, cache control,
14-context management, latency control, and priority control — and exposes the14+context management, session control, latency control, and priority control —
15-translator that converts ``CacheControl.msg_offset`` and15+and exposes the translator that converts ``CacheControl.msg_offset`` and
16``ContextEdit.start/end`` from message-level indices into PagedAttention block16``ContextEdit.start/end`` from message-level indices into PagedAttention block
17coordinates consumed by the scheduler.17coordinates consumed by the scheduler.
18"""18"""
19 19 
20+from enum import Enum, auto
20from typing import Any21from typing import Any
21-from pydantic import BaseModel, Field, field_validator22+ 
23+from pydantic import BaseModel, Field, ValidationError, field_validator
24+ 
22from motor.common.logger import get_logger25from motor.common.logger import get_logger
23 26 
24logger = get_logger(__name__)27logger = 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",
tobking
tobkingtobking8月17日

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 + 拒绝下发)。

likedislike
zengwei
zengwei
8月17日 评论:
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+ @field_validator("type", mode="before")
293+ @classmethod
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+ 
270class LatencyControl(BaseModel):306class 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 None544 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
Gganglv8月17日

严重程度: 建议

问题: session_control 请求缺少 session_id 时,注入的管理请求没有目标会话,且全程无任何告警,操作被静默吞掉。

原因: 实测验证:session_control={"type": "pause"} 且请求体/header 均无 session_id 时,autofill 注入 manage_request=True, target=session 的 context_management,但 parse_agent_hintsession_id=None,下游 attach_block_offsets(block_offset_translator.py 中 if hint is None or not getattr(hint, "session_id", None): return)直接返回,引擎收到的管理请求没有会话标识。session_control 的语义(pause/stop/compact/resume 作用于某个会话)强依赖 session_id 才能生效,客户端漏传时用户以为操作已执行,实际什么都没发生。这是新特性新增的用户面,比既有显式 context_management 更依赖会话标识。

怎么改: 在 ACTIONABLE 分支(注入前或注入时)检测最终可解析的 session_id:若请求体与 header 均无有效 session_id,输出 warning 日志(如 session_control.type=stop 无有效 session_id,管理请求可能无法定位目标会话),或在 _resolve_session_ids 已有告警的基础上补一句针对 session_control 的提示;同时在 SessionControl 注释中明确该前置条件。

likedislike
zengwei
zengwei
8月17日 评论:
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+ 
502def _parse_priority_control(data: dict) -> PriorityControl | None:725def _parse_priority_control(data: dict) -> PriorityControl | None:
503 priority_control = None726 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 = None836 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 return873 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
35from motor.coordinator.tracer.tracing import TracerManager35from motor.coordinator.tracer.tracing import TracerManager
36from motor.coordinator.domain.request_manager import RequestManager36from motor.coordinator.domain.request_manager import RequestManager
37from motor.coordinator.domain.agent_hint import (37from 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)
atomgit-botatomgit-bot
atomgit-botatomgit-bot8月14日

🟠 High Priority

变更点:motor/coordinator/router/dispatch.py 第 371 行把原来的 ensure_minimum_messages_for_session_edits(request_json, req_data) 调用替换成了 apply_session_control_autofill(request_json),而不是在其之前插入,导致消息注入函数在生产路径上彻底不再被调用(全仓库 grep 确认:ensure_minimum_messages_for_session_edits 现在只剩 agent_hint.py 的定义、dispatch.py 的 import,以及测试里的直接调用,没有任何生产调用方)。

受影响行为/契约:apply_session_control_autofill 的 docstring 明确写「注入的 context_management 对 downstream 的 ensure_minimum_messages_for_session_editsparse_agent_hintattach_block_offsets 可见」,说明该函数本应在 autofill 之后继续运行;新增的单元测试 test_session_control_autofill_then_minimum_messages_injection 也是按「先 autofill 再 ensure_minimum_messages」的顺序编写的。ensure_minimum_messages_for_session_edits 的作用是:当 context_management.manage_request=true 且 edits 含有 session 目标时,把空/缺失的 messages 注入为默认 system/user 两条消息,日志明确说这是为了「prevent apply_chat_template out-of-bounds crash downstream」。

失败模式:两类请求会带着空 messages 通过 _validate_openai_request 的非空校验(① 已有的 context_management.manage_request=true + target=session 绕过分支;② 本次新增的 session_control=stop/pause/compact/resume 绕过分支),随后进入 __create_request_info。由于 ensure_minimum_messages_for_session_edits 不再执行,req_data(转发给引擎的请求体)里 messages 仍为空,下游 apply_chat_template(messages, ...) 对空列表越界崩溃/返回 500。这是对既有 manage-request 特性(以及新 session_control 特性)的直接功能回归,触发条件明确(空 messages + 管理类请求),概率高。

建议:把 apply_session_control_autofill(request_json) 之前/之后的调用恢复为两条:先 autofill,再 ensure_minimum_messages_for_session_edits;并补充一条不经 mock handle_request 的分发顺序测试。

改动建议
371
- apply_session_control_autofill(request_json)
371
+ apply_session_control_autofill(request_json)
372
+ ensure_minimum_messages_for_session_edits(request_json, req_data)
应用建议
likedislike
不准确?
zengwei
zengwei
8月14日 评论:
atomgit-botatomgit-bot8月14日

🟠 High Priority

变更行:motor/coordinator/router/dispatch.py:371 将原先的 ensure_minimum_messages_for_session_edits(request_json, req_data) 调用替换成了 apply_session_control_autofill(request_json)(该文件 diff 统计为 +2 -1:新增 import 一行、新增 autofill 调用一行、删除注入调用一行;当前文件中 ensure_minimum_messages_for_session_edits 仍被 import,但全 motor/ 目录已无任何生产调用点)。

影响的行为/契约:__create_request_info 中本应在“autofill 之后、parse_agent_hint 之前”执行的占位消息注入(ensure_minimum_messages_for_session_edits)现在完全不执行,导致空 messages 的管理请求不再被补上默认 system/user 消息。

失效模式:inference_server.py:134-137 新增了 session_control_implies_manage_request(body_json.get("agent_hint")) 放行逻辑,使 agent_hint.session_control 为 pause/stop/compact/resume 且 messages=[] 的请求通过 400 校验(原有 context_management.manage_request=true + session 目标编辑的放行路径也继续存在)。随后 dispatch 中 apply_session_control_autofill 只注入 context_managementmessages 仍为空列表,直接下发到下游 apply_chat_template,触发 ensure_minimum_messages_for_session_editsagent_hint.py:806/815 的注释)明确要预防的 "apply_chat_template out-of-bounds crash downstream"。

补充证据:新增单测 test_session_control_autofill_then_minimum_messages_injection 直接手动调用两个函数、TestCoordinatorServerAdvancedhandle_request 又被 mock(test_http_server.py:851-854),均未走真实 dispatch 链路,因此该回归未被任何测试捕获。

建议:在 autofill 之后恢复占位消息注入调用,保证空 messages 的 session_control / context_management 管理请求在进入 parse_agent_hint 与下游之前被补上 system/user 占位消息。

改动建议
371
- apply_session_control_autofill(request_json)
371
+ apply_session_control_autofill(request_json)
372
+ ensure_minimum_messages_for_session_edits(request_json, req_data)
应用建议
likedislike
不准确?
zengwei
zengwei
8月14日 评论:
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
16from motor.coordinator.domain.agent_hint import (16from motor.coordinator.domain.agent_hint import (
17 CacheControl,17 CacheControl,
18 ContextEdit,18 ContextEdit,
19+ SessionControl,
tobking
tobkingtobking8月17日

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 端点支持,或补齐豁免逻辑。

likedislike
zengwei
zengwei
8月17日 评论:
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"] == messages129 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+@pytest.mark.parametrize(
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+@pytest.mark.parametrize(
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+@pytest.mark.parametrize(
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+@pytest.mark.parametrize(
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+@pytest.mark.parametrize(
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+ 
128def test_parse_agent_hint_resolves_lowercase_headers_from_real_request():392def test_parse_agent_hint_resolves_lowercase_headers_from_real_request():
129 """Regression: Starlette normalizes header keys to lowercase; parse_agent_hint393 """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):