已合并
[Feature]Add AgentHint feature for agent inference #692
[Feature]Add AgentHint feature for agent inference #692
已合并
zengwei创建于 19 天前
9 个文件变更+2567-3
Mmotor/coordinator/api_server/inference_server.py+43-1
@@ -39,6 +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_request
42 43 
43logger = get_logger(__name__)44logger = get_logger(__name__)
44 45 
@@ -48,6 +49,40 @@ def get_request_manager(request: Request) -> RequestManager:
48 return request.app.state.request_manager49 return request.app.state.request_manager
49 50 
50 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
jason lyu
jason lyujason lyu19 天前

edit 缺 target 时默认 session,可能把 messages 编辑当 session 处理,建议确认。

likedislike
zengwei
zengwei
16 天前 评论:
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")
atomgit-bot
atomgit-botatomgit-bot19 天前

🟡 Medium Priority

_has_session_target_edit (line 50-70) 仅检查 agent_hint.context_management.edits 中是否存在 target="session" 的编辑,未检查 manage_request 字段。而 ensure_minimum_messages_for_session_edits (agent_hint.py line 583-632) 在注入默认消息时同时要求 manage_request=true 且存在 session 编辑。

两者不一致导致:

  • 空 messages 继续向下游传递,在 apply_chat_template 中可能导致崩溃或异常行为。

建议:在 _has_session_target_edit 中,在遍历 edits 之前增加对 manage_request 的检查: 若 context_management.get("manage_request") 为假值则直接返回 False。建议复用 ContextManagement._validate_manage_request 以确保与解析路径一致。

likedislike
zengwei
zengwei
16 天前 评论:
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):
jason lyu
jason lyujason lyu15 天前

_is_manage_request 是函数对象,条件 _is_manage_request 恒为真,与消息列表无关。应调用 _is_manage_request(body_json)。

likedislike
82+ return False
83+ return parse_manage_request(context_management.get("manage_request"))
84+ 
85+ 
51def _validate_anthropic_request(body_json: dict[str, Any], *, require_max_tokens: bool = True) -> None:86def _validate_anthropic_request(body_json: dict[str, Any], *, require_max_tokens: bool = True) -> None:
52 """Validate Anthropic-style request body. Raises HTTPException on invalid."""87 """Validate Anthropic-style request body. Raises HTTPException on invalid."""
53 if not body_json.get("model"):88 if not body_json.get("model"):
@@ -91,7 +126,14 @@ def _validate_openai_request(body_json: dict[str, Any], request_type: RequestTyp
91 )126 )
92 if OpenAIField.MESSAGES not in body_json:127 if OpenAIField.MESSAGES not in body_json:
93 return128 return
94- if not isinstance(body_json[OpenAIField.MESSAGES], list) or len(body_json[OpenAIField.MESSAGES]) == 0:129+ if not isinstance(body_json[OpenAIField.MESSAGES], list):
130+ raise HTTPException(
131+ status_code=status.HTTP_400_BAD_REQUEST,
132+ detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array",
133+ )
134+ if len(body_json[OpenAIField.MESSAGES]) == 0 and not (
135+ _is_manage_request(body_json) and _has_session_target_edit(body_json)
136+ ):
95 raise HTTPException(137 raise HTTPException(
96 status_code=status.HTTP_400_BAD_REQUEST,138 status_code=status.HTTP_400_BAD_REQUEST,
97 detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array",139 detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array",
Amotor/coordinator/domain/agent_hint.py+682-0
@@ -0,0 +1,682 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+"""AgentHint for the request layer.
11+ 
12+Defines the Pydantic schemas that parse and validate the ``agent_hint`` block
13+on incoming requests — session/parent-session identifiers, cache control,
14+context management, latency control, and priority control — and exposes the
15+translator that converts ``CacheControl.msg_offset`` and
16+``ContextEdit.start/end`` from message-level indices into PagedAttention block
17+coordinates consumed by the scheduler.
18+"""
19+ 
20+from typing import Any
G
Gganglv18 天前

严重程度: 建议

问题: 本文件缺少 Mulan PSL v2 license 头(对比同 PR 新增的 block_offset_translator.py 有完整头);且 model_validatorSelf 两个 import 未被使用;文件 563 行存在尾随空白。

原因: 仓库 pre-commit 强制 check-header(每个 Python 文件第一行必须是 license 头)、ruff F401(未使用 import)、trailing-whitespace 钩子,三项都会导致 CI 失败(本 PR 已挂 ci-pipeline-failed 标签)。同时本 PR 未附带任何测试文件,AGENTS.md 要求"每个 motor/ 改动必须附带测试",1723 行的偏移计算算法(DSV4 marker 扫描、char→token 锚定、对齐策略)无 UT 覆盖回归风险很高。

怎么改:

  1. 补齐 license 头;2) 删除未使用的 model_validatorSelf import;3) 清理尾随空白;4) 为 agent_hint / block_offset_translator / inference_server 校验补齐 UT(覆盖:无 session 请求不告警、空 messages 各组合、edits 部分丢弃、msg_offset 边界、DSV4/标准 tokenizer 的 offset 表)。
likedislike
zengwei
zengwei
15 天前 评论:
21+from pydantic import BaseModel, Field, field_validator
22+from motor.common.logger import get_logger
23+ 
24+logger = get_logger(__name__)
25+ 
26+# Header fallback field names (lowercase to match Starlette's header normalization)
27+HEADER_SESSION_ID = "x-session-id"
28+HEADER_PARENT_SESSION_ID = "x-parent-session-id"
29+_AGENT_HINT_KNOWN_FIELDS = frozenset(
30+ {
31+ "session_id",
32+ "parent_session_id",
33+ "cache_control",
34+ "context_management",
35+ "latency_control",
36+ "priority_control",
37+ }
38+)
39+_CACHE_TYPE_ALLOWED = "ephemeral"
40+_CACHE_TTL_DEFAULT = 300
41+_CACHE_TTL_MIN = 60
42+_CACHE_TTL_MAX = 3600
43+_CACHE_MSG_OFFSET_DEFAULT = None
44+_EDIT_TYPES_ALLOWED = frozenset({"offload", "prefetch", "evict"})
45+_EDIT_TARGETS_ALLOWED = frozenset({"session", "messages", "tools"})
46+_EDIT_TARGET_DEFAULT = "session"
47+_CACHE_SERVER_ONLY_FIELDS = frozenset({"block_offset", "intra_block_offset", "token_offset"})
48+_EDIT_SERVER_ONLY_FIELDS = frozenset(
49+ {
50+ "block_start",
51+ "block_intra_start",
52+ "start_token",
53+ "block_end",
54+ "block_intra_end",
55+ "end_token",
56+ }
57+)
58+ 
59+ 
60+class CacheControl(BaseModel):
61+ """KV cache control."""
62+ 
63+ type: str = Field(default="ephemeral", description="Cache type; only 'ephemeral' is supported.")
64+ ttl: int = Field(default=_CACHE_TTL_DEFAULT, description="Cache TTL in seconds; clamped to [60, 3600].")
65+ msg_offset: int | None = Field(
66+ default=_CACHE_MSG_OFFSET_DEFAULT,
67+ description=(
68+ "1-based message index at which cache_control takes effect; None means apply to the whole conversation."
69+ ),
70+ )
71+ block_offset: int | None = Field(
72+ default=None,
73+ description="Block index (token_idx // block_size); filled by attach_block_offsets server-side.",
74+ )
75+ intra_block_offset: int | None = Field(
76+ default=None,
77+ description="Intra-block offset (token_idx % block_size); filled by attach_block_offsets server-side.",
78+ )
79+ token_offset: int | None = Field(
80+ default=None,
81+ description="Cumulative token count (= block_offset*block_size + intra_block_offset); filled by attach_block_offsets server-side.",
82+ )
83+ 
84+ @field_validator("type", mode="before")
85+ @classmethod
86+ def _validate_type(cls, value: Any) -> str:
87+ if value is None or (isinstance(value, str) and value == ""):
88+ return _CACHE_TYPE_ALLOWED
89+ value = str(value)
90+ if value != _CACHE_TYPE_ALLOWED:
91+ logger.warning(
92+ "Invalid cache_control.type=%r; dropping cache_control (only %r is supported).",
93+ value,
94+ _CACHE_TYPE_ALLOWED,
95+ )
96+ raise ValueError(f"unsupported cache_control.type: {value!r}")
97+ return value
98+ 
99+ @field_validator("ttl", mode="before")
100+ @classmethod
101+ def _validate_ttl(cls, value: Any) -> int:
102+ try:
103+ value = int(value)
104+ except (TypeError, ValueError):
105+ logger.warning(
106+ "Invalid cache_control.ttl=%s; using default %s",
107+ value,
108+ _CACHE_TTL_DEFAULT,
109+ )
110+ return _CACHE_TTL_DEFAULT
111+ if value < _CACHE_TTL_MIN or value > _CACHE_TTL_MAX:
112+ clamped = max(_CACHE_TTL_MIN, min(_CACHE_TTL_MAX, value))
113+ logger.warning(
114+ "cache_control.ttl=%s out of range [%s, %s]; clamped to %s",
115+ value,
116+ _CACHE_TTL_MIN,
117+ _CACHE_TTL_MAX,
118+ clamped,
119+ )
120+ return clamped
121+ return value
122+ 
123+ @field_validator("msg_offset", mode="before")
124+ @classmethod
125+ def _validate_msg_offset(cls, value: Any) -> int | None:
126+ if value is None:
127+ return None
128+ try:
129+ return int(value)
130+ except (TypeError, ValueError):
131+ logger.warning(
132+ "Invalid cache_control.msg_offset=%s; treating as unset (None).",
133+ value,
134+ )
135+ return None
136+ 
137+ 
138+class ContextEdit(BaseModel):
139+ """Context edit operation."""
140+ 
141+ type: str = Field(..., description="One of 'offload' / 'prefetch' / 'evict'; must be specified explicitly.")
142+ start: int | None = Field(default=None, description="Inclusive start msg index; None = 0.")
143+ end: int | None = Field(
144+ default=None,
145+ description="Exclusive end msg index (Python-slice convention: edits cover messages[start:end]); None = len(messages) (include all).",
146+ )
147+ target: str = Field(
148+ default=_EDIT_TARGET_DEFAULT,
149+ description="What to operate on; 'session' (default) / 'messages' / 'tools'.",
150+ )
151+ block_start: int | None = Field(
152+ default=None,
153+ description="block_idx for start message index; filled by server.",
154+ )
155+ block_intra_start: int | None = Field(
156+ default=None,
157+ description="intra_block_offset for start message index; filled by server.",
158+ )
159+ start_token: int | None = Field(
160+ default=None,
161+ description="token_idx for start message index; filled by server.",
162+ )
163+ block_end: int | None = Field(
164+ default=None,
165+ description="block_idx for end message index; filled by server.",
166+ )
167+ block_intra_end: int | None = Field(
168+ default=None,
169+ description="intra_block_offset for end message index; filled by server.",
170+ )
171+ end_token: int | None = Field(
172+ default=None,
173+ description="token_idx for end message index; filled by server.",
174+ )
175+ 
176+ @field_validator("type", mode="before")
177+ @classmethod
178+ def _validate_type(cls, value: Any) -> str:
179+ value = str(value)
180+ if value not in _EDIT_TYPES_ALLOWED:
181+ logger.warning(
182+ "Unsupported context_edit.type=%r; expected one of %s. Dropping edit.",
183+ value,
184+ sorted(_EDIT_TYPES_ALLOWED),
185+ )
186+ raise ValueError(f"unsupported context_edit.type: {value!r}")
187+ return value
188+ 
189+ @field_validator("start", mode="before")
190+ @classmethod
191+ def _validate_start(cls, value: Any) -> int | None:
192+ try:
193+ if value is None:
194+ return None
195+ value = int(value)
196+ except (TypeError, ValueError):
197+ logger.warning("Invalid context_edit.start=%s; using default %s", value, None)
198+ return None
199+ if value < 0:
200+ logger.warning("context_edit.start=%s less than %s", value, 0)
201+ return value
202+ 
203+ @field_validator("end", mode="before")
204+ @classmethod
205+ def _validate_end(cls, value: Any) -> int | None:
206+ try:
207+ if value is None:
208+ return None
209+ value = int(value)
210+ except (TypeError, ValueError):
211+ logger.warning("Invalid context_edit.end=%s; using default %s", value, None)
212+ return None
213+ if value < 0:
214+ logger.warning("context_edit.end=%s less than %s", value, 0)
215+ return value
216+ 
217+ @field_validator("target", mode="before")
218+ @classmethod
219+ def _validate_target(cls, value: Any) -> str:
220+ if value is None:
221+ return _EDIT_TARGET_DEFAULT
222+ value = str(value)
223+ if value not in _EDIT_TARGETS_ALLOWED:
224+ logger.warning(
225+ "Invalid context_edit.target=%r; using default %r.",
226+ value,
227+ _EDIT_TARGET_DEFAULT,
228+ )
229+ return _EDIT_TARGET_DEFAULT
230+ return value
231+ 
232+ 
233+def parse_manage_request(value: Any) -> bool:
234+ """Parse a raw `manage_request` value into bool.
235+ 
236+ Accepts JSON bool, 0/1 integers, and case-insensitive 'true'/'false' strings.
237+ Any other type falls back to False (with a warning logged).
238+ """
239+ if isinstance(value, bool):
240+ return value
241+ if isinstance(value, int) and value in (0, 1):
242+ return bool(value)
243+ if isinstance(value, str) and value.lower() in ("true", "false"):
244+ return value.lower() == "true"
245+ logger.warning(
246+ "Invalid context_management.manage_request=%r; using False",
247+ value,
248+ )
249+ return False
250+ 
251+ 
252+class ContextManagement(BaseModel):
253+ """Context management."""
254+ 
255+ manage_request: bool = Field(
256+ default=False,
257+ description=(
258+ "True if this is a KVC management request; the request body itself "
259+ "is not executed, only the context edits are processed."
260+ ),
261+ )
262+ edits: list[ContextEdit] = Field(default_factory=list, description="List of context edit operations.")
263+ 
264+ @field_validator("manage_request", mode="before")
265+ @classmethod
266+ def _validate_manage_request(cls, value: Any) -> bool:
267+ return parse_manage_request(value)
268+ 
269+ 
270+class LatencyControl(BaseModel):
271+ """Latency/SLO hint (design-only)."""
272+ 
273+ latency_sensitivity: int | None = Field(default=None, description="Latency sensitivity hint (ms).")
274+ # More fields may be added in future versions.
275+ 
276+ 
277+class PriorityControl(BaseModel):
278+ """Priority hint (design-only)."""
279+ 
280+ priority: int | None = Field(default=None, description="Priority hint; higher value means higher priority.")
281+ # More fields may be added in future versions.
282+ 
283+ 
284+class AgentHintInfo(BaseModel):
285+ """
286+ Structured info parsed from the OpenAI request's agent_hint.
287+ 
288+ In the minimal version, only session_id / parent_session_id / cache_control
289+ are consumed by the Scheduler. context_management / latency_control /
290+ priority_control are parsed and populated for forward compatibility but
291+ do not currently drive scheduling decisions.
292+ """
293+ 
294+ session_id: str | None = Field(default=None, description="Session ID; supplied by client or auto-generated.")
295+ parent_session_id: str | None = Field(
296+ default=None, description="Parent session ID; usually the main agent's session."
297+ )
298+ cache_control: CacheControl | None = Field(default=None, description="KV cache control hint.")
299+ context_management: ContextManagement | None = Field(
300+ default=None, description="Context management hint (design-only)."
301+ )
302+ 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).")
304+ raw_extra: dict | None = Field(
305+ default_factory=dict, description="Pass-through dict for unrecognized extension fields in agent_hint."
306+ )
307+ 
308+ 
309+def _parse_cache_control(
310+ data: Any,
311+ messages: list | None = None,
312+) -> CacheControl | None:
313+ if not isinstance(data, dict):
314+ return None
315+ data = {k: v for k, v in data.items() if k not in _CACHE_SERVER_ONLY_FIELDS}
316+ try:
317+ cache_control = CacheControl(**data)
318+ except Exception as e:
319+ logger.warning("Failed to parse cache_control: %s", e)
320+ return None
321+ 
322+ n = len(messages) if isinstance(messages, list) else None
323+ msg_offset = cache_control.msg_offset
324+ if msg_offset is None:
325+ if n is not None and n > 0:
326+ cache_control.msg_offset = n
327+ else:
328+ if msg_offset < 1:
G
Gganglv18 天前

严重程度: 建议

问题: CacheControl.msg_offset 的 docstring(第 38 行)声称 "0-based message index",但实现按 1-based 处理:此处要求 msg_offset >= 1(=0 直接整体丢弃 cache_control),compute_block_offseteffective = msg_offset - 1

原因: 实测 msg_offset: 0 → cache_control 被静默丢弃(_parse_cache_control 返回 None)。按文档发送 0(意图"从第 0 条消息起生效")的客户端会无感知丢失 KV 缓存控制,且没有任何日志提示该字段实际是 1-based。

怎么改: 修正 docstring 为 "1-based(TTL 保护 messages[0, msg_offset) 前缀边界)",或兼容 msg_offset=0 并给出明确告警。

likedislike
zengwei
zengwei
15 天前 评论:
329+ logger.warning(
330+ "cache_control.msg_offset=%s out of range [1, %s]; dropping cache_control.",
331+ msg_offset,
332+ (n) if n is not None else "len(messages)",
333+ )
334+ return None
335+ if n is not None and msg_offset > n:
336+ logger.warning(
337+ "cache_control.msg_offset=%s out of range [0, %s]; dropping cache_control.",
338+ msg_offset,
339+ n,
340+ )
341+ return None
342+ return cache_control
343+ 
344+ 
345+def _validate_edit_indices(
346+ edit: ContextEdit,
347+ messages: list | None,
348+ tools: list | None = None,
349+) -> bool:
350+ """
351+ Validate ContextEdit.start / ContextEdit.end:
352+ 
353+ - start / end must be >= 0.
354+ - Bounds:
355+ * target="tools": [0, len(tools)].
356+ * otherwise: [0, len(messages)].
357+ - start < end when both are non-None; start >= end is invalid.
358+ 
359+ Returns False (with WARNING) when any check fails; callers should drop
360+ the entry.
361+ """
362+ msg_count: int | None
363+ if isinstance(messages, list):
364+ msg_count = len(messages)
365+ else:
366+ msg_count = None
367+ 
368+ # target="tools" validates indices against len(tools), not len(messages).
369+ if edit.target == "tools" and isinstance(tools, list):
370+ bound = len(tools)
371+ bound_name = "len(tools)"
372+ use_tools_bound = True
373+ else:
374+ bound = msg_count
375+ bound_name = "len(messages)"
376+ use_tools_bound = False
377+ 
378+ start = edit.start
379+ end = edit.end
380+ dropped = False
381+ 
382+ if start is not None and start < 0:
383+ logger.warning(
384+ "context_edit.start=%s less than 0; dropping edit (type=%s, target=%s).",
385+ start,
386+ edit.type,
387+ edit.target,
388+ )
389+ dropped = True
390+ if end is not None and end < 0:
391+ logger.warning(
392+ "context_edit.end=%s less than 0; dropping edit (type=%s, target=%s).",
393+ end,
394+ edit.type,
395+ edit.target,
396+ )
397+ dropped = True
398+ 
399+ if bound is not None:
400+ if start is not None and start > bound:
401+ logger.warning(
402+ "context_edit.start=%s > %s=%s; dropping edit (type=%s, target=%s).",
403+ start,
404+ bound_name,
405+ bound,
406+ edit.type,
407+ edit.target,
408+ )
409+ dropped = True
410+ if end is not None and end > bound:
411+ logger.warning(
412+ "context_edit.end=%s > %s=%s; dropping edit (type=%s, target=%s).",
413+ end,
414+ bound_name,
415+ bound,
416+ edit.type,
417+ edit.target,
418+ )
419+ dropped = True
420+ 
421+ if start is not None and end is not None and start >= end:
422+ logger.warning(
423+ "context_edit.start=%s >= end=%s; dropping edit (type=%s, target=%s).",
424+ start,
425+ end,
426+ edit.type,
427+ edit.target,
428+ )
429+ dropped = True
430+ 
431+ # target="tools" with empty tools: bounds are meaningless; warn here for parity
432+ # with the offset translator, which also short-circuits.
433+ if use_tools_bound and bound == 0:
434+ logger.warning(
435+ "context_edit target='tools' but tools is empty; edit (type=%s) will be a no-op downstream.",
436+ edit.type,
437+ )
438+ 
439+ return not dropped
440+ 
441+ 
442+def _parse_context_management(
443+ data: Any,
444+ messages: list | None = None,
445+ tools: list | None = None,
446+) -> ContextManagement | None:
447+ if not isinstance(data, dict):
448+ return None
449+ try:
450+ try:
451+ manage_request = parse_manage_request(data.get("manage_request", False))
452+ except TypeError:
453+ manage_request = False
454+ 
455+ edits_raw = data.get("edits") or []
456+ edits: list[ContextEdit] = []
457+ for entry in edits_raw:
458+ if not isinstance(entry, dict):
459+ logger.warning(
460+ "Ignoring non-dict context_management.edits entry: %r",
461+ entry,
462+ )
463+ continue
464+ forged = sorted(k for k in entry if k in _EDIT_SERVER_ONLY_FIELDS)
465+ if forged:
466+ logger.warning(
467+ "context_management.edits entry contains server-only "
468+ "offset field(s) %s; dropping entire edit (type=%r) to "
469+ "prevent client forgery.",
470+ forged,
471+ entry.get("type"),
472+ )
473+ continue
474+ try:
475+ edit = ContextEdit(**entry)
476+ except Exception as exc:
477+ logger.warning(
478+ "Failed to parse context_management.edits entry: %s",
479+ exc,
480+ )
481+ continue
482+ 
483+ if not _validate_edit_indices(edit, messages, tools):
484+ continue
485+ edits.append(edit)
486+ 
487+ if not manage_request and not edits:
488+ return None
489+ 
490+ if manage_request and not edits:
491+ logger.warning(
492+ "context_management.manage_request=true with no usable edits; "
493+ "dropping context_management (set to None)."
494+ )
495+ return None
496+ return ContextManagement(manage_request=manage_request, edits=edits)
497+ except Exception as exc:
498+ logger.warning("Failed to parse context_management: %s", exc)
499+ return None
500+ 
501+ 
502+def _parse_priority_control(data: dict) -> PriorityControl | None:
503+ priority_control = None
504+ if isinstance(data, dict):
505+ try:
506+ priority_control = PriorityControl(priority=data.get("priority"))
507+ except Exception as e:
508+ logger.warning("Failed to parse priority_control: %s", e)
509+ return priority_control
510+ 
511+ 
512+def _is_valid_session_id(value: Any) -> bool:
513+ """Valid session id: must be a non-empty string. None / empty / non-string all invalid."""
514+ return isinstance(value, str) and value != ""
515+ 
516+ 
517+def _resolve_session_ids(
518+ agent_hint_data: Any,
519+ headers: dict | None = None,
520+) -> tuple[str | None, str | None]:
521+ """
522+ Resolve and normalize session_id / parent_session_id.
523+ 
524+ Per-field priority:
525+ 1. The field in agent_hint (request body).
526+ 2. Otherwise fall back to x-session-id / x-parent-session-id header
527+ (Starlette normalizes header keys to lowercase).
528+ 
529+ Complement rule (single-agent clients typically send only one):
530+ - Only session_id valid -> parent_session_id := session_id.
531+ - Only parent_session_id valid -> session_id := parent_session_id.
532+ - Both valid -> keep as-is (parent/child hierarchy is
533+ the client's decision).
534+ - Both invalid -> normalize to None; do not generate IDs.
535+ If the client attempted to send them
536+ (body key present or headers present),
537+ emit a WARNING.
538+ 
539+ Returns:
540+ (session_id, parent_session_id): both non-empty strings, or both None.
541+ """
542+ # Non-dict agent_hint (e.g. str/list): treat as empty dict to keep .get safe.
jason lyu
jason lyujason lyu19 天前

tools 为空时走 msg 分支校验索引,行为不一致,建议显式判断并跳过。

likedislike
zengwei
zengwei
15 天前 评论:
543+ if not isinstance(agent_hint_data, dict):
544+ agent_hint_data = {}
545+ 
546+ session_id = agent_hint_data.get("session_id")
547+ parent_session_id = agent_hint_data.get("parent_session_id")
548+ # Distinguish "client didn't send" from "client sent but invalid"; only the
549+ # latter (or present headers) should warn — avoids log spam on plain
550+ # requests without agent_hint.
551+ body_has_any_session_id = "session_id" in agent_hint_data or "parent_session_id" in agent_hint_data
552+ 
553+ # Fall back to headers when a field's body value is invalid.
554+ if headers:
555+ if not _is_valid_session_id(session_id):
556+ session_id = headers.get(HEADER_SESSION_ID)
tobking
tobkingtobking14 天前

P2:真实请求中的 Starlette/FastAPI headers 会把键规范化为小写(例如 x-session-id),但这里使用大小写敏感的 X-Session-Id / X-Parent-Session-Id 读取,导致仅通过 Header 传入的 session ID 被丢弃。当前 UT 传入的是手工构造的大写 dict,未覆盖真实 Request。建议统一使用小写键或不区分大小写的 Header API,并补集成测试。

likedislike
zengwei
zengwei
14 天前 评论:
557+ if not _is_valid_session_id(parent_session_id):
558+ parent_session_id = headers.get(HEADER_PARENT_SESSION_ID)
559+ 
560+ # Apply complement rule to fill in any missing field.
561+ session_valid = _is_valid_session_id(session_id)
562+ parent_valid = _is_valid_session_id(parent_session_id)
563+ if session_valid and not parent_valid:
564+ logger.warning("parent_session_id is invalid(missing/empty/non-string), set parent_session_id = session_id")
565+ parent_session_id = session_id
566+ elif parent_valid and not session_valid:
567+ logger.warning("session_id is invalid(missing/empty/non-string), set session_id = parent_session_id")
568+ session_id = parent_session_id
569+ elif not session_valid and not parent_valid:
570+ # Neither field has a usable value: normalize to None (overwrites dirty
571+ # values such as empty strings or non-strings).
572+ if body_has_any_session_id or headers:
G
Gganglv18 天前

严重程度: 建议

问题: 告警条件 if body_has_any_session_id or headers:headers 是全部请求头(dispatch.py 传 dict(raw_request.headers),含 Content-Type/Host/X-Request-Id 等,几乎恒非空),导致每个不带 session_id 的普通请求都会打 WARNING。

原因: 实测:一个完全不带 agent_hint 的普通请求即触发 session_id and parent_session_id are both missing/empty/non-string; passthrough 告警(agent_hint.py:507)。这与上方注释 "avoids log spam on plain requests without agent_hint" 的意图完全相反——在高吞吐推理网关下该告警会淹没真实告警通道。

怎么改: 改为只检查 session 相关 header:

headers_have_session = bool(
    headers and (headers.get(HEADER_SESSION_ID) or headers.get(HEADER_PARENT_SESSION_ID))
)
if body_has_any_session_id or headers_have_session:
    logger.warning(...)
likedislike
zengwei
zengwei
15 天前 评论:
573+ logger.warning(
574+ "session_id and parent_session_id are both missing/empty/non-string; passthrough (both will be None)"
575+ )
576+ session_id = None
577+ parent_session_id = None
578+ 
579+ return session_id, parent_session_id
580+ 
581+ 
582+def parse_agent_hint(
583+ request_json: dict,
584+ headers: dict | None = None,
585+) -> AgentHintInfo:
586+ agent_hint_data = request_json.get("agent_hint", {}) if isinstance(request_json, dict) else {}
587+ if not isinstance(agent_hint_data, dict):
588+ # agent_hint exists but is not a mapping (e.g. str/list) — treat as empty.
589+ logger.warning(
590+ "agent_hint is not a dict (got %s); falling back to defaults",
591+ type(agent_hint_data).__name__,
592+ )
593+ agent_hint_data = {}
594+ 
595+ session_id, parent_session_id = _resolve_session_ids(agent_hint_data, headers)
596+ 
597+ messages = request_json.get("messages") if isinstance(request_json, dict) else None
598+ tools = request_json.get("tools") if isinstance(request_json, dict) else None
599+ 
600+ cache_control = _parse_cache_control(
601+ agent_hint_data.get("cache_control"),
602+ messages=messages,
603+ )
604+ 
605+ context_management = _parse_context_management(
606+ agent_hint_data.get("context_management"),
607+ messages=messages,
jason lyu
jason lyujason lyu19 天前

新文件未包含 Mulan PSL v2 license 头,违反项目硬性约束。

likedislike
zengwei
zengwei
15 天前 评论:
608+ tools=tools,
609+ )
610+ 
611+ latency_control = None
612+ lc_data = agent_hint_data.get("latency_control")
613+ if isinstance(lc_data, dict):
614+ try:
615+ latency_control = LatencyControl(latency_sensitivity=lc_data.get("latency_sensitivity"))
616+ except Exception as e:
617+ logger.warning("Failed to parse latency_control: %s", e)
618+ 
619+ priority_control = _parse_priority_control(agent_hint_data.get("priority_control"))
620+ 
621+ raw_extra = {}
jason lyu
jason lyujason lyu19 天前

ensure_minimum_messages 修改了局部列表但未回写 request_json,下游可能读不到。

likedislike
zengwei
zengwei
15 天前 评论:
622+ for key, value in agent_hint_data.items():
623+ if key not in _AGENT_HINT_KNOWN_FIELDS:
624+ raw_extra[key] = value
625+ 
626+ return AgentHintInfo(
627+ session_id=session_id,
628+ parent_session_id=parent_session_id,
629+ cache_control=cache_control,
630+ context_management=context_management,
631+ latency_control=latency_control,
632+ priority_control=priority_control,
633+ raw_extra=raw_extra,
634+ )
635+ 
636+ 
637+_DEFAULT_SYSTEM_MESSAGE: dict[str, str] = {"role": "system", "content": "context management messages"}
638+_DEFAULT_USER_MESSAGE: dict[str, str] = {"role": "user", "content": "context management messages"}
639+ 
640+ 
641+def ensure_minimum_messages_for_session_edits(
642+ request_json: dict,
643+ req_data: dict,
644+) -> None:
645+ agent_hint_data = request_json.get("agent_hint", {}) if isinstance(request_json, dict) else {}
646+ if not isinstance(agent_hint_data, dict):
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
662+ 
663+ messages = request_json.get("messages")
664+ if messages is None:
665+ new_list = [_DEFAULT_SYSTEM_MESSAGE, _DEFAULT_USER_MESSAGE]
G
Gganglv18 天前

严重程度: 提示

问题: 注入的假消息 {"role":"system","content":"context management messages"} 会随 req_data 原样转发给引擎(router/strategies/base.py 的 forward 原样 POST req_data);且 attach_block_offsetssession_id 缺失时 early return(block_offset_translator.py:988),纯 context_management(无 session id)请求不会得到 block 级偏移。

原因: 若引擎不识别 manage_request=true,会按真实消息对假内容做生成;若下游期待 block 级坐标,则无 session id 的编辑请求会静默保持消息索引语义而不生效。

怎么改: 确认引擎对 manage_request 的处理契约;必要时把 session_id 缺失但含 context_management 的请求也纳入 offset 计算,并在接口文档中明确该前置条件。

likedislike
zengwei
zengwei
15 天前 评论:
666+ request_json["messages"] = new_list
667+ req_data["messages"] = new_list
668+ logger.warning(
669+ "Injecting default system and user message into empty/missing messages "
670+ "list for manage_request=true session-targeted edit; "
671+ "prevents apply_chat_template out-of-bounds crash downstream."
672+ )
673+ return
674+ if isinstance(messages, list) and len(messages) == 0:
675+ messages.append(_DEFAULT_SYSTEM_MESSAGE)
676+ messages.append(_DEFAULT_USER_MESSAGE)
677+ logger.warning(
678+ "Injecting default system and user message into empty messages list "
679+ "for manage_request=true session-targeted edit; "
680+ "prevents apply_chat_template out-of-bounds crash downstream."
681+ )
682+ return
Amotor/coordinator/domain/block_offset_translator.py+1202-0
@@ -0,0 +1,1202 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of Mulan PSL v2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Translate ``CacheControl.msg_offset`` and ``ContextEdit.start/end`` from
12+message-level indices into PagedAttention block coordinates.
13+"""
14+ 
15+from __future__ import annotations
16+from abc import ABC, abstractmethod
17+from typing import Any, Callable
18+from motor.common.logger import get_logger
19+from motor.coordinator.api_client.conductor_api_client import ConductorApiClient
20+ 
21+logger = get_logger(__name__)
22+ 
23+_EDIT_TYPE_OFFSET_COMPUTERS: dict[str, Callable] = {}
24+ 
25+ 
26+# DSV4 (vLLM DeepseekV4Tokenizer) chat template uses 3 special tokens as
27+# message-boundary markers:
28+# <|User|> : start of user/developer/tool-merged messages.
29+# <|Assistant|> : transition at end of user-like messages (also the
30+# logical start of an assistant message).
31+# <|end of sentence|> : actual end of an assistant message.
32+# Note: DSV4 user/system/developer messages carry NO eos; only assistant does.
33+# Prefix boundaries are therefore tracked along two separate rails: the
34+# "role-start marker" rail and the "eos" rail.
35+_DSV4_USER_MARKER: str = "<|User|>"
36+_DSV4_ASSISTANT_MARKER: str = "<|Assistant|>"
37+_DSV4_EOS_MARKER: str = "<|end▁of▁sentence|>"
38+ 
39+_CHAT_MARKER_CANDIDATES = (
40+ "<|im_start|>", # Qwen / Qwen2 / Qwen3
41+ "<|begin_of_text|>", # Llama-3
42+ "<|user|>", # Some chat-tuned models
43+ "<|sep|>", # Mistral / Mixtral
44+)
45+ 
46+ 
47+def _is_dsv4_tokenizer(tokenizer: Any) -> bool:
48+ """Detect vLLM's DeepSeekV4 tokenizer wrapper.
49+ 
50+ vLLM's deepseek_v4.py:80 sets ``_DeepseekV4Tokenizer.__name__ =
51+ f"DSV4{...}"`` as a stable contract. But ``from_pretrained`` wraps the
52+ class again via ``get_cached_tokenizer``, which renames it to
53+ ``f"Cached{...}"`` — so the leaf class becomes ``CachedDSV4TokenizersBackend``
54+ and a leaf-only check would miss. We walk the whole MRO to stay robust
55+ against extra wrapping layers.
56+ """
57+ if tokenizer is None:
58+ return False
59+ try:
60+ mro = type(tokenizer).__mro__
61+ except Exception: # noqa: BLE001 — defensive: type() shouldn't raise
62+ return False
63+ return any((getattr(cls, "__name__", "") or "").startswith("DSV4") for cls in mro)
64+ 
65+ 
66+def _try_get_token_id(tokenizer: Any, tok_str: str) -> int | None:
67+ """Pure helper for the DSV4 markers.
68+ 
69+ Returns the single-token id for ``tok_str``, or None if it is not a
70+ single token (or tokenization fails).
71+ """
72+ if tokenizer is None:
73+ return None
74+ try:
75+ tid = tokenizer.convert_tokens_to_ids(tok_str)
76+ except Exception: # noqa: BLE001 — tokenizer API quirks
77+ return None
78+ if tid is None or getattr(tokenizer, "unk_token_id", None) == tid:
79+ return None
80+ try:
81+ enc = tokenizer.encode(tok_str, add_special_tokens=False)
82+ except Exception: # noqa: BLE001
83+ return None
84+ return tid if (len(enc) == 1 and enc[0] == tid) else None
85+ 
86+ 
87+# ---------------------------------------------------------------------------
88+# Model-family block-offset calculators
89+# ---------------------------------------------------------------------------
90+#
91+# Each model family has its own chat-template rendering, so the prefix / tools
92+# offset algorithm is per-family. Encapsulated as ``BaseBlockOffsetCalculator``
93+# subclasses; adding a new model means one subclass + one factory line.
94+# ``attach_block_offsets`` only calls ``preprocess / compute_messages /
95+# compute_tools`` and is agnostic to model differences.
96+#
97+# Design choice: a single calculator (covering preprocess + messages + tools)
98+# rather than two split calculators keeps "preprocess once" locked inside one
99+# object, avoiding skip-or-duplicate inconsistency when prefix and tools would
100+# otherwise run preprocessing independently.
101+ 
102+ 
103+class BaseBlockOffsetCalculator(ABC):
104+ """Block-offset calculator for a model family.
105+ 
106+ Owns its full chat-template logic (messages-to-token and tools-to-token
107+ offsets) inside the class, so callers only need to invoke the three
108+ public methods: ``preprocess / compute_messages / compute_tools``.
109+ """
110+ 
111+ @abstractmethod
112+ def preprocess(self, messages, tools):
113+ """Model-family preprocessing of messages/tools. Default is identity;
114+ DSV4 flattens multipart content and sorts tool results. Returns
115+ ``(messages, tools)``.
116+ """
117+ ...
118+ 
119+ @abstractmethod
120+ def compute_messages(self, messages, tools, tokenizer):
121+ """Returns a monotonic non-decreasing list of length ``len(messages)``,
122+ or ``None`` on failure (caller falls back to per-call partial tokenize).
123+ """
124+ ...
125+ 
126+ @abstractmethod
127+ def compute_tools(self, messages, tools, tokenizer):
128+ """Returns a list of length ``len(tools) + 1`` (each tool's start
129+ plus a trailing sentinel), or ``None`` on failure.
130+ """
131+ ...
132+ 
133+ 
134+class Dsv4BlockOffsetCalculator(BaseBlockOffsetCalculator):
135+ """DSV4-specific calculator.
136+ 
137+ Encapsulates the full DSV4 algorithm:
138+ - ``_try_build_messages_offset_table_via_marker_dsv4``:
139+ marker-scan fast path (<|User|> / <|Assistant|> / <|end of sentence|>).
140+ - ``_build_messages_offset_table_dsv4``:
141+ thin wrapper around the fast path. Returns ``None`` on failure —
142+ DSV4 tokenization is deterministic, so a missing marker means a
143+ template mismatch (no iterative fallback).
144+ - ``_build_tools_offset_table_dsv4``:
145+ single-pass tokenize + decode + name_marker + reverse ``{`` scan.
146+ - ``_resolve_tools_end_pos_dsv4``:
147+ tools-segment end sentinel (char position of the first ``<|User|>``);
148+ falls back to ``len(full_str)`` when it returns ``None``.
149+ """
150+ 
151+ def preprocess(self, messages, tools):
152+ from motor.coordinator.scheduler.policy.utils import (
153+ preprocess_messages_for_dsv4,
154+ )
155+ 
156+ return preprocess_messages_for_dsv4(messages, tools)
157+ 
158+ def compute_messages(self, messages, tools, tokenizer):
159+ return self._build_messages_offset_table_dsv4(messages, tools, tokenizer)
160+ 
161+ def compute_tools(self, messages, tools, tokenizer):
162+ return self._build_tools_offset_table_dsv4(messages, tools, tokenizer)
163+ 
164+ def _build_messages_offset_table_dsv4(
165+ self,
166+ messages,
167+ tools,
168+ tokenizer,
169+ ) -> list[int] | None:
170+ """DSV4 messages offset table: marker-only fast path; returns ``None`` on failure."""
171+ fast = self._try_build_messages_offset_table_via_marker_dsv4(
172+ messages,
173+ tools,
174+ tokenizer,
175+ )
176+ if fast is not None:
177+ return fast
178+ logger.debug(
179+ "dsv4 marker-based messages table unavailable (N=%d)",
180+ len(messages or []),
181+ )
182+ return None
183+ 
184+ def _try_build_messages_offset_table_via_marker_dsv4(
185+ self,
186+ messages,
187+ tools,
188+ tokenizer,
189+ ) -> list[int] | None:
190+ """DSV4 marker-scan fast path.
191+ 
192+ Rules:
193+ - End of a user-like message (user/developer/tool/merged-tool) =
194+ the next role marker in the token stream (``<|User|>`` or
195+ ``<|Assistant|>``, whichever comes first). This handles both
196+ user→assistant (transition) and user→user (next ``<|User|>``).
197+ - End of an assistant message = the next ``<|end of sentence|>`` + 1.
198+ - When ``messages[0]`` is system, the system segment ends at the
199+ first ``<|User|>``.
200+ 
201+ Returns ``None`` on any failure (missing marker, count mismatch,
202+ unknown role, tokenize error); callers decide what to do next.
203+ """
204+ if not messages or not isinstance(messages, list):
205+ return None
206+ 
207+ eos_id = _try_get_token_id(tokenizer, _DSV4_EOS_MARKER)
208+ user_id = _try_get_token_id(tokenizer, _DSV4_USER_MARKER)
209+ asst_id = _try_get_token_id(tokenizer, _DSV4_ASSISTANT_MARKER)
210+ if eos_id is None or user_id is None or asst_id is None:
211+ return None
212+ 
213+ # Single full tokenize (same shape as _apply_chat_template_dsv4).
214+ try:
215+ ids = tokenizer.apply_chat_template(
216+ messages,
217+ tools=tools,
218+ add_generation_prompt=True,
219+ tokenize=True,
220+ return_dict=False,
221+ )
222+ except TypeError:
223+ try:
224+ ids = tokenizer.apply_chat_template(
225+ messages,
226+ tools=tools,
227+ add_generation_prompt=True,
228+ tokenize=True,
229+ )
230+ except Exception as exc: # noqa: BLE001
231+ logger.debug("dsv4 marker tokenize (no return_dict) failed: %s", exc)
232+ return None
233+ except Exception as exc: # noqa: BLE001
234+ logger.debug("dsv4 marker tokenize failed: %s", exc)
235+ return None
236+ 
237+ if not isinstance(ids, list) or len(ids) == 0:
238+ return None
239+ 
240+ # Positions for each of the 3 markers (single pass, O(L_total)).
241+ user_pos: list[int] = [i for i, t in enumerate(ids) if t == user_id]
242+ asst_pos: list[int] = [i for i, t in enumerate(ids) if t == asst_id]
243+ eos_pos: list[int] = [i for i, t in enumerate(ids) if t == eos_id]
244+ 
245+ n = len(messages)
246+ has_system_at_start = n > 0 and isinstance(messages[0], dict) and messages[0].get("role") == "system"
247+ 
248+ result: list[int] = []
249+ user_pos_idx = 1 if has_system_at_start else 0
250+ asst_pos_idx = 0
251+ eos_consumed = 0
252+ 
253+ for i, msg in enumerate(messages):
254+ if not isinstance(msg, dict):
255+ return None
256+ role = msg.get("role")
257+ if i == 0 and has_system_at_start:
258+ if not user_pos:
259+ return None
260+ result.append(user_pos[0])
261+ elif role in ("user", "developer", "tool"):
262+ consumed = False
263+ if asst_pos_idx < len(asst_pos):
264+ next_user = user_pos[user_pos_idx + 1] if user_pos_idx + 1 < len(user_pos) else None
265+ if next_user is None or asst_pos[asst_pos_idx] < next_user:
266+ result.append(asst_pos[asst_pos_idx])
267+ asst_pos_idx += 1
268+ consumed = True
269+ if not consumed:
270+ if user_pos_idx + 1 < len(user_pos):
271+ result.append(user_pos[user_pos_idx + 1])
272+ consumed = True
273+ if not consumed:
274+ return None
275+ user_pos_idx += 1
276+ elif role == "assistant":
277+ if eos_consumed >= len(eos_pos):
278+ return None
279+ result.append(eos_pos[eos_consumed] + 1)
280+ eos_consumed += 1
281+ else:
282+ return None # Unknown role: fall back to per-call prefix.
283+ 
284+ if len(result) != n:
285+ return None
286+ return result
287+ 
288+ def _build_tools_offset_table_dsv4(
289+ self,
290+ messages,
291+ tools,
292+ tokenizer,
293+ ) -> list[int] | None:
294+ """DSV4 tools table built from a single render.
295+ 
296+ Instead of N calls of
297+ ``apply_chat_template(tools=[t_i])``, do a single
298+ ``apply_chat_template(tools=tools, tokenize=True)`` to obtain the full
299+ ids, ``decode`` to a string, then locate each tool's start via
300+ ``name_marker`` + a reverse ``{`` scan.
301+ """
302+ if tokenizer is None or not tools or not isinstance(tools, list):
303+ return None
304+ if not isinstance(messages, list):
305+ return None
306+ 
307+ # 1. Single full tokenize.
308+ try:
309+ ids = tokenizer.apply_chat_template(
310+ messages,
311+ tools=tools,
312+ add_generation_prompt=True,
313+ tokenize=True,
314+ return_dict=False,
315+ )
316+ except TypeError:
317+ try:
318+ ids = tokenizer.apply_chat_template(
319+ messages,
320+ tools=tools,
321+ add_generation_prompt=True,
322+ tokenize=True,
323+ )
324+ except Exception as exc: # noqa: BLE001 — fail-open
325+ logger.debug("dsv4 tools table: tokenize failed: %s", exc)
326+ return None
327+ except Exception as exc: # noqa: BLE001 — fail-open
328+ logger.debug("dsv4 tools table: tokenize failed: %s", exc)
329+ return None
330+ 
331+ if not isinstance(ids, list) or len(ids) == 0:
332+ return None
333+ 
334+ # 2. Decode to string once.
335+ try:
336+ full_str = tokenizer.decode(ids)
337+ except Exception as exc: # noqa: BLE001 — fail-open
338+ logger.debug("dsv4 tools table: decode failed: %s", exc)
339+ return None
340+ if not isinstance(full_str, str) or not full_str:
341+ return None
342+ 
343+ # 3. Per tool: name_marker + reverse '{' scan.
344+ char_positions: list[int] = []
345+ for tool in tools:
346+ if not isinstance(tool, dict):
347+ return None
348+ fn = tool.get("function") or {}
349+ if not isinstance(fn, dict):
350+ return None
351+ tool_name = str(fn.get("name") or "")
352+ if not tool_name:
353+ return None
354+ search_from = char_positions[-1] if char_positions else 0
355+ name_marker = f'"name": "{tool_name}"'
356+ nm_idx = full_str.find(name_marker, search_from)
357+ if nm_idx < 0:
358+ return None
359+ obj_open = full_str.rfind('{', search_from, nm_idx)
360+ if obj_open < 0:
361+ return None
362+ char_positions.append(obj_open)
363+ 
364+ # 4. End of tools segment: reuse _resolve_tools_end_pos_dsv4.
365+ end_pos = self._resolve_tools_end_pos_dsv4(full_str, char_positions)
366+ if end_pos is None:
367+ end_pos = len(full_str)
368+ char_positions.append(end_pos)
369+ 
370+ # 5. char → token.
371+ token_positions: list[int] = []
372+ try:
373+ for cp in char_positions:
374+ prefix_ids = tokenizer.encode(full_str[:cp], add_special_tokens=False)
375+ token_positions.append(len(prefix_ids))
376+ except Exception as exc: # noqa: BLE001 — fail-open
377+ logger.debug("dsv4 tools table: prefix encode failed: %s", exc)
378+ return None
379+ 
380+ # 6. Monotonicity check.
381+ for i in range(1, len(token_positions)):
382+ if token_positions[i] < token_positions[i - 1]:
383+ return None
384+ 
385+ return token_positions
386+ 
387+ def _resolve_tools_end_pos_dsv4(
388+ self,
389+ full_str,
390+ char_positions,
391+ ) -> int | None:
392+ """DSV4 tools-segment char end (first ``<|User|>``).
393+ 
394+ Returns ``None`` (caller falls back to ``len(full_str)``) when:
395+ - ``<|User|>`` is not in ``full_str``.
396+ - ``<|User|>`` position <= last tool start (anomalous; conservative).
397+ - ``full_str.find`` raises.
398+ """
399+ if not char_positions:
400+ return None
401+ try:
402+ um = full_str.find(_DSV4_USER_MARKER)
403+ except Exception: # noqa: BLE001 — defensive
404+ return None
405+ if um <= char_positions[-1]:
406+ return None
407+ return um
408+ 
409+ 
410+class StandardBlockOffsetCalculator(BaseBlockOffsetCalculator):
411+ """Generic calculator for Qwen / Llama-3 / Mistral.
412+ 
413+ Encapsulates the standard algorithm:
414+ - ``_build_messages_offset_table``: chat-marker counting.
415+ - ``_detect_chat_marker_id``: first single-token marker id from
416+ ``_CHAT_MARKER_CANDIDATES``.
417+ - ``_build_tools_offset_table``: 1+N renders + char anchors +
418+ ``</tools>`` closer (original ``build_tools_offset_table`` body).
419+ """
420+ 
421+ def preprocess(self, messages, tools):
422+ return messages, tools # identity
423+ 
424+ def compute_messages(self, messages, tools, tokenizer):
425+ return self._build_messages_offset_table(messages, tools, tokenizer)
426+ 
427+ def compute_tools(self, messages, tools, tokenizer):
428+ return self._build_tools_offset_table(messages, tools, tokenizer)
429+ 
430+ def _build_messages_offset_table(
431+ self,
432+ messages,
433+ tools,
434+ tokenizer,
435+ ) -> list[int] | None:
436+ """Generic chat-marker counting: cumulative token offsets per message index."""
437+ if tokenizer is None:
438+ return None
439+ if not messages or not isinstance(messages, list):
440+ return None
441+ 
442+ marker_id = self._detect_chat_marker_id(tokenizer)
443+ if marker_id is None:
444+ logger.warning(
445+ "no chat-template marker token found in tokenizer vocab; "
446+ "messages offset table unavailable, callers must fall back"
447+ )
448+ return None
449+ 
450+ try:
451+ ids = tokenizer.apply_chat_template(
452+ messages,
453+ tools=tools,
454+ add_generation_prompt=True,
455+ tokenize=True,
456+ return_dict=False,
457+ )
458+ except TypeError:
459+ ids = tokenizer.apply_chat_template(
460+ messages,
461+ tools=tools,
462+ add_generation_prompt=True,
463+ tokenize=True,
464+ )
465+ except Exception as exc: # noqa: BLE001 — fail-open
466+ logger.warning(
467+ "build_messages_offset_table: full tokenization failed: %s",
468+ exc,
469+ )
470+ return None
471+ 
472+ if not isinstance(ids, list) or len(ids) == 0:
473+ return None
474+ 
475+ im_starts = [i for i, t in enumerate(ids) if t == marker_id]
476+ expected_with_gen = len(messages) + 1
477+ expected_no_gen = len(messages)
478+ if len(im_starts) == expected_with_gen:
479+ return [im_starts[i + 1] for i in range(len(messages))]
480+ if len(im_starts) == expected_no_gen:
481+ return [im_starts[i + 1] if i + 1 < len(im_starts) else len(ids) for i in range(len(messages))]
482+ logger.warning(
483+ "build_messages_offset_table: marker count %d unexpected "
484+ "(messages=%d, expected %d or %d); table unavailable",
485+ len(im_starts),
486+ len(messages),
487+ expected_with_gen,
488+ expected_no_gen,
489+ )
490+ return None
491+ 
492+ def _detect_chat_marker_id(self, tokenizer) -> int | None:
493+ """Find the first single-token marker id in ``_CHAT_MARKER_CANDIDATES``."""
494+ if tokenizer is None:
495+ return None
496+ for tok in _CHAT_MARKER_CANDIDATES:
497+ try:
498+ tid = tokenizer.convert_tokens_to_ids(tok)
499+ except Exception: # noqa: BLE001 — tokenizer API quirks
500+ continue
501+ if tid is not None and getattr(tokenizer, "unk_token_id", None) != tid:
502+ try:
503+ enc = tokenizer.encode(tok, add_special_tokens=False)
504+ except Exception:
505+ continue
506+ if len(enc) == 1 and enc[0] == tid:
507+ return tid
508+ return None
509+ 
510+ def _build_tools_offset_table(
511+ self,
512+ messages,
513+ tools,
514+ tokenizer,
515+ ) -> list[int] | None:
516+ """1+N renders + char anchors + ``</tools>`` closer.
517+ 
518+ Original ``build_tools_offset_table`` body moved here verbatim — same
519+ semantics, same control flow. The trailing sentinel falls back to
520+ ``len(full_str)`` when no closer matches (DSV4 template, no
521+ ``</tools>`` wrapping).
522+ """
523+ if tokenizer is None:
524+ return None
525+ if not tools or not isinstance(tools, list):
526+ return None
527+ if not isinstance(messages, list):
528+ return None
529+ 
530+ # 1. Render the full prompt as a string.
531+ try:
532+ full_str = tokenizer.apply_chat_template(
533+ messages,
534+ tools=tools,
535+ add_generation_prompt=True,
536+ tokenize=False,
537+ return_dict=False,
538+ )
539+ except TypeError:
540+ try:
541+ full_str = tokenizer.apply_chat_template(
542+ messages,
543+ tools=tools,
544+ add_generation_prompt=True,
545+ tokenize=False,
546+ )
547+ except Exception as exc: # noqa: BLE001 — fail-open
548+ logger.warning(
549+ "build_tools_offset_table: full tokenize failed: %s",
550+ exc,
551+ )
552+ return None
553+ except Exception as exc: # noqa: BLE001 — fail-open
554+ logger.warning(
555+ "build_tools_offset_table: full tokenize failed: %s",
556+ exc,
557+ )
558+ return None
559+ 
560+ if not isinstance(full_str, str) or not full_str:
561+ return None
562+ 
563+ # 2. For each tool, locate its character position in full_str.
564+ char_positions: list[int] = []
565+ for i, tool in enumerate(tools):
566+ # 2a. Render this single tool to capture its exact rendered form.
567+ try:
568+ s_i = tokenizer.apply_chat_template(
569+ messages,
570+ tools=[tool],
571+ add_generation_prompt=True,
572+ tokenize=False,
573+ return_dict=False,
574+ )
575+ except TypeError:
576+ try:
577+ s_i = tokenizer.apply_chat_template(
578+ messages,
579+ tools=[tool],
580+ add_generation_prompt=True,
581+ tokenize=False,
582+ )
583+ except Exception as exc: # noqa: BLE001
584+ logger.warning(
585+ "build_tools_offset_table: single-tool render for tool[%d] failed: %s",
586+ i,
587+ exc,
588+ )
589+ return None
590+ except Exception as exc: # noqa: BLE001
591+ logger.warning(
592+ "build_tools_offset_table: single-tool render for tool[%d] failed: %s",
593+ i,
594+ exc,
595+ )
596+ return None
597+ 
598+ if not isinstance(s_i, str) or not s_i:
599+ logger.warning(
600+ "build_tools_offset_table: single-tool render for tool[%d] returned empty/non-string",
601+ i,
602+ )
603+ return None
604+ 
605+ # 2b. Locate the tool's name in s_i (unique anchor).
606+ tool_name = ""
607+ if isinstance(tool, dict):
608+ fn = tool.get("function") or {}
609+ if isinstance(fn, dict):
610+ tool_name = str(fn.get("name") or "")
611+ if not tool_name:
612+ logger.warning(
613+ "build_tools_offset_table: tool[%d] has no function.name; cannot anchor",
614+ i,
615+ )
616+ return None
617+ name_marker = f'"name": "{tool_name}"'
618+ s_i_name_idx = s_i.find(name_marker)
619+ if s_i_name_idx < 0:
620+ logger.warning(
621+ "build_tools_offset_table: name %r not found in single-tool render for tool[%d]",
622+ tool_name,
623+ i,
624+ )
625+ return None
626+ 
627+ # 2c. Locate this tool's JSON opener in full_str by walking forward
628+ # through `{"type": "function"` occurrences and matching the next name.
629+ search_from = char_positions[-1] if char_positions else 0
630+ candidate_prefix = '{"type": "function"'
631+ idx = full_str.find(candidate_prefix, search_from)
632+ found = False
633+ while idx >= 0:
634+ next_name_idx = full_str.find('"name":', idx)
635+ if next_name_idx < 0:
636+ break
637+ name_start = full_str.find('"', next_name_idx + len('"name":'))
638+ if name_start < 0:
639+ break
640+ name_end = full_str.find('"', name_start + 1)
641+ if name_end < 0:
642+ break
643+ found_name = full_str[name_start + 1 : name_end]
644+ if found_name == tool_name:
645+ char_positions.append(idx)
646+ found = True
647+ break
648+ idx = full_str.find(candidate_prefix, idx + 1)
649+ if not found:
650+ # Fallback: chat template may not use {"type": "function" wrapper
651+ # (e.g., Llama-3 inlines tools differently). Use name_marker
652+ # itself as anchor and back-walk to the nearest `{`.
653+ nm = full_str.find(name_marker, search_from)
654+ if nm < 0:
655+ logger.warning(
656+ "build_tools_offset_table: tool[%d] (%s) not located in full_str",
657+ i,
658+ tool_name,
659+ )
660+ return None
661+ obj_open = full_str.rfind('{', search_from, nm)
662+ if obj_open < 0:
663+ logger.warning(
664+ "build_tools_offset_table: tool[%d] (%s) opener `{` not found before name",
665+ i,
666+ tool_name,
667+ )
668+ return None
669+ char_positions.append(obj_open)
670+ 
671+ # 3. End-of-tools-segment char position: prefer `</tools>` closer if present.
672+ end_pos = len(full_str)
673+ for closer in ('</tools>', '</tools_section>'):
674+ idx = full_str.rfind(closer)
675+ if idx > char_positions[-1]:
676+ end_pos = idx
677+ break
678+ char_positions.append(end_pos)
679+ 
680+ # 4. Convert char positions → token positions via prefix encodes.
681+ token_positions: list[int] = []
682+ try:
683+ for cp in char_positions:
684+ prefix_ids = tokenizer.encode(full_str[:cp], add_special_tokens=False)
685+ token_positions.append(len(prefix_ids))
686+ except Exception as exc: # noqa: BLE001 — fail-open
687+ logger.warning(
688+ "build_tools_offset_table: encode prefix failed: %s",
689+ exc,
690+ )
691+ return None
692+ 
693+ # 5. Sanity check: monotonic non-decreasing.
694+ for i in range(1, len(token_positions)):
695+ if token_positions[i] < token_positions[i - 1]:
696+ logger.warning(
697+ "build_tools_offset_table: non-monotonic token position at index %d (%d < %d)",
698+ i,
699+ token_positions[i],
700+ token_positions[i - 1],
701+ )
702+ return None
703+ 
704+ return token_positions
705+ 
706+ 
707+def get_block_offset_calculator(tokenizer) -> BaseBlockOffsetCalculator:
708+ """Pick the right calculator for the tokenizer type.
709+ 
710+ Centralized here so adding a new model family touches only this function.
711+ """
712+ if _is_dsv4_tokenizer(tokenizer):
713+ return Dsv4BlockOffsetCalculator()
714+ return StandardBlockOffsetCalculator()
715+ 
716+ 
717+def _register_edit_type(name: str) -> Callable[[Callable], Callable]:
718+ """Decorator: register a per-edit-type offset computer.
719+ 
720+ Usage::
721+ 
722+ @_register_edit_type("compact")
723+ def _compact_offset(messages, tools, msg_idx, block_size, tokenizer):
724+ ...
725+ """
726+ 
727+ def deco(fn: Callable) -> Callable:
728+ _EDIT_TYPE_OFFSET_COMPUTERS[name] = fn
729+ return fn
730+ 
731+ return deco
732+ 
733+ 
734+def _prefix_diff_block_offset(
735+ messages: list,
736+ tools: list | None,
737+ msg_idx: int,
738+ block_size: int,
739+ tokenizer: Any,
740+ messages_table: list[int] | None = None,
741+) -> tuple[int, int, int] | None:
742+ if not messages or not isinstance(messages, list):
743+ return None
744+ if msg_idx < 0 or msg_idx >= len(messages):
745+ return None
746+ if messages_table is None or msg_idx >= len(messages_table):
747+ # No messages_table or out-of-range: cannot derive precise coords
748+ # without per-call partial tokenize. Return None so attach_block_offsets
749+ # falls back to its own per-call partial tokenization.
750+ return None
751+ 
752+ token_idx = messages_table[msg_idx]
753+ if token_idx <= 0:
754+ return None
755+ 
756+ return (token_idx // block_size, token_idx % block_size, token_idx)
atomgit-bot
atomgit-botatomgit-bot19 天前

🟡 Medium Priority

compute_block_offset (line 841) 有对 block_size 的校验 (<= 0 时返回 None),但以下路径缺少同等防护:

  1. _prefix_diff_block_offset (line 721): token_idx // block_sizetoken_idx % block_size 无 block_size 校验。该函数被 _offload_offset_computer / _prefetch_offset_computer / _evict_offset_computer 调用,这些又被 compute_edit_block_offset (messages 路径) 调用。

  2. compute_edit_block_offset tools 路径 (line 926, 930, 934): ts // block_sizets % block_sizete % block_size 均无校验。

block_size 来自配置 kv_conductor_config.block_size(默认 128),若配置异常被设为 0,请求会在 attach_block_offsetscompute_edit_block_offset 路径抛出 ZeroDivisionError,导致调度阶段崩溃。

建议:在 compute_edit_block_offset 入口处增加与 compute_block_offset 同款的 block_size 校验(<= 0 时记录 WARNING 并返回 (None, None))。另外在 _prefix_diff_block_offset 开头也增加 block_size <= 0 的提前返回 None。

likedislike
757+ 
758+ 
759+def _align_block_for_op(
760+ token_idx: int,
761+ block_size: int,
762+ align_policy: str,
763+ boundary: str,
764+) -> int:
765+ """Align an absolute token offset ``token_idx`` to a block index.
766+ 
767+ Args:
768+ token_idx: Absolute token offset (range start or end).
769+ block_size: PagedAttention block size; <= 0 is invalid.
770+ align_policy: Alignment policy (see below). Dispatch paths pass
771+ ``"positive"`` (see ``*_offset_computer``s) or the raw ``edit_type``
772+ string (tools branch of ``compute_edit_block_offset``).
773+ boundary: ``"start"`` / ``"end"`` — whether token_idx is the range's
774+ start or end. Only used by ``negative``; rounding direction
775+ differs for start vs end.
776+ 
777+ Policy semantics:
778+ - ``negative`` (shrink): keep only full blocks inside [start, end];
779+ prefer under-shooting to touching out-of-range tokens.
780+ * start mid-block (intra != 0) -> round up to next block.
781+ * end not at block tail (intra != size-1) -> round down to prev block.
782+ * Exactly on a boundary -> no rounding; result clamped to >= 0.
783+ - ``positive`` (truncate): use ``token_idx // block_size`` — the block
784+ containing token_idx, which may include out-of-range edge tokens.
785+ - Unknown values: behave like ``positive`` (fail-open, no exception).
786+ 
787+ Returns:
jason lyu
jason lyujason lyu19 天前

target=session 时直接返回 (None,None),attach 只写 start/end,下游拿不到块坐标。

likedislike
zengwei
zengwei
15 天前 评论:
788+ Aligned block index (>= 0). Invalid block_size fails open to 0.
789+ """
790+ # Invalid block_size (<=0): skip division and fail-open to block 0.
791+ if block_size <= 0:
jason lyu
jason lyujason lyu16 天前

except TypeError分支中使用了isinstance(full_str, str),但TypeError可能由decode抛caused,此时full_str未赋值。

likedislike
zengwei
zengwei
15 天前 评论:
792+ return 0
793+ block = token_idx // block_size
794+ intra = token_idx % block_size
795+ match align_policy:
796+ case "negative":
797+ # Shrink: start rounds up, end rounds down — only full blocks.
798+ if boundary == "start":
799+ # intra == 0: start aligns with block head; that block is in range.
800+ aligned = block + (1 if intra != 0 else 0)
801+ elif boundary == "end":
802+ # intra == block_size-1: end aligns with block tail; that block is in range.
803+ aligned = block - (1 if intra != block_size - 1 else 0)
804+ else:
805+ # Invalid boundary: degenerate to truncation, no directional rounding.
806+ aligned = block
807+ # End rounding may yield -1 (token_idx mid-block 0); clamp to 0.
808+ return max(0, aligned)
809+ case "positive":
810+ # Truncation: take the block containing token_idx, regardless of boundary.
811+ return block
812+ case _:
813+ # Unknown policy == positive; safe for newly added unregistered edit_types.
814+ return block
815+ 
816+ 
817+@_register_edit_type("offload")
818+def _offload_offset_computer(
819+ messages: list,
820+ tools: list | None,
821+ msg_idx: int,
822+ block_size: int,
823+ tokenizer: Any,
824+ boundary: str,
825+ messages_table: list[int] | None = None,
826+) -> tuple[int, int, int] | None:
827+ bo = _prefix_diff_block_offset(
828+ messages,
829+ tools,
830+ msg_idx,
831+ block_size,
832+ tokenizer,
833+ messages_table=messages_table,
834+ )
835+ if bo is None:
836+ return None
837+ _, intra, token_idx = bo
838+ aligned = _align_block_for_op(token_idx, block_size, "positive", boundary)
839+ return (aligned, intra, token_idx)
840+ 
841+ 
842+@_register_edit_type("prefetch")
843+def _prefetch_offset_computer(
844+ messages: list,
845+ tools: list | None,
846+ msg_idx: int,
847+ block_size: int,
848+ tokenizer: Any,
849+ boundary: str,
850+ messages_table: list[int] | None = None,
851+) -> tuple[int, int, int] | None:
852+ bo = _prefix_diff_block_offset(
853+ messages,
854+ tools,
855+ msg_idx,
856+ block_size,
857+ tokenizer,
858+ messages_table=messages_table,
859+ )
860+ if bo is None:
861+ return None
862+ _, intra, token_idx = bo
863+ aligned = _align_block_for_op(token_idx, block_size, "positive", boundary)
864+ return (aligned, intra, token_idx)
865+ 
866+ 
867+@_register_edit_type("evict")
868+def _evict_offset_computer(
869+ messages: list,
870+ tools: list | None,
871+ msg_idx: int,
872+ block_size: int,
873+ tokenizer: Any,
874+ boundary: str,
875+ messages_table: list[int] | None = None,
876+) -> tuple[int, int, int] | None:
877+ bo = _prefix_diff_block_offset(
878+ messages,
879+ tools,
880+ msg_idx,
881+ block_size,
882+ tokenizer,
883+ messages_table=messages_table,
884+ )
885+ if bo is None:
886+ return None
887+ _, intra, token_idx = bo
888+ aligned = _align_block_for_op(token_idx, block_size, "positive", boundary)
889+ return (aligned, intra, token_idx)
890+ 
891+ 
892+def compute_block_offset(
893+ messages: list,
894+ tools: list | None,
895+ msg_offset: int | None,
896+ block_size: int,
897+ tokenizer: Any,
898+ messages_table: list[int] | None = None,
899+) -> tuple[int, int, int] | None:
900+ if not isinstance(block_size, int) or block_size <= 0:
901+ logger.warning("invalid block_size=%r; cannot compute block_offset", block_size)
902+ return None
903+ if not messages or not isinstance(messages, list):
904+ logger.warning("messages is empty or invalid; cannot compute block_offset")
905+ return None
906+ if messages_table is None:
907+ # No messages_table: cannot give precise coords. attach_block_offsets
908+ # already logged a WARNING upstream; just return None here.
909+ return None
910+ 
911+ effective = msg_offset - 1 # msg_offset range [1, n]; TTL protects messages[0, msg_offset).
912+ if effective < 0 or effective >= len(messages_table):
913+ return None
914+ token_idx = messages_table[effective]
915+ 
916+ if token_idx == 0:
917+ logger.warning(
918+ "token_idx == 0 for effective msg_offset=%d; tokenizer configuration suspect?",
919+ effective,
920+ )
921+ return None
922+ 
923+ return (token_idx // block_size, token_idx % block_size, token_idx)
924+ 
925+ 
926+def compute_edit_block_offset(
927+ edit_type: str,
928+ messages: list,
929+ tools: list | None,
930+ start: int | None,
931+ end: int | None,
932+ block_size: int,
933+ tokenizer: Any,
934+ target: str = "messages",
935+ messages_table: list[int] | None = None,
936+ tools_table: list[int] | None = None,
937+) -> tuple[
938+ tuple[int, int, int] | None,
939+ tuple[int, int, int] | None,
940+]:
941+ if not isinstance(block_size, int) or block_size <= 0:
942+ logger.warning("invalid block_size=%r; cannot compute edit_block_offset", block_size)
943+ return None
944+ 
945+ if target == "session":
946+ logger.info(
947+ "target='session' for edit_type=%r; overriding start/end to [None, None] and skipping offset computation",
948+ edit_type,
949+ )
950+ return (None, None)
951+ 
952+ if target == "tools":
953+ if not tools or not isinstance(tools, list):
954+ logger.warning(
955+ "target='tools' but tools is empty/None; returning (None, None)",
956+ )
957+ return (None, None)
958+ if not (isinstance(tools_table, list) and len(tools_table) == len(tools) + 1):
959+ logger.warning(
960+ "target='tools' but tools_offset_table unavailable "
961+ "(tools_table=%r, len(tools)=%d); returning (None, None)",
962+ tools_table if isinstance(tools_table, list) else type(tools_table).__name__,
963+ len(tools),
964+ )
965+ return (None, None)
966+ 
967+ n_tools = len(tools)
968+ eff_start = 0 if start is None else max(0, min(start, n_tools))
969+ eff_end = n_tools if end is None else max(0, min(end, n_tools))
970+ 
971+ if start is not None and end is not None and start > end:
972+ logger.warning(
973+ "context_edit.start=%d > end=%d (target='tools'); not corrected",
974+ start,
975+ end,
976+ )
977+ 
978+ # tools_table[i] = absolute token offset of tools[i]'s first token
979+ # in the FULL render stream.
980+ if eff_start == 0:
981+ ts = tools_table[0]
982+ bo_start = (ts // block_size, ts % block_size, ts)
983+ else:
984+ ts = tools_table[eff_start]
985+ aligned = _align_block_for_op(ts, block_size, edit_type, "start")
986+ bo_start = (aligned, ts % block_size, ts)
987+ 
988+ te = tools_table[eff_end]
989+ aligned_e = _align_block_for_op(te, block_size, edit_type, "end")
990+ bo_end = (aligned_e, te % block_size, te)
991+ 
992+ if eff_start == eff_end:
993+ # Empty range (parity with messages path): bo_end signals "no end offset".
994+ return (bo_start, None)
995+ return (bo_start, bo_end)
996+ 
997+ if edit_type not in _EDIT_TYPE_OFFSET_COMPUTERS:
998+ logger.warning(
999+ "Unknown edit_type=%r; skipping offset conversion (registered: %s)",
1000+ edit_type,
1001+ sorted(_EDIT_TYPE_OFFSET_COMPUTERS.keys()),
1002+ )
1003+ return (None, None)
1004+ 
1005+ computer = _EDIT_TYPE_OFFSET_COMPUTERS[edit_type]
1006+ n = len(messages) if isinstance(messages, list) else 0
1007+ 
1008+ if n == 0:
1009+ return (None, None)
1010+ 
1011+ eff_start = 0 if start is None else (max(0, min(start, n - 1)))
1012+ eff_end = n if end is None else (max(0, min(end, n)))
1013+ 
1014+ if start is not None and end is not None and start > end:
1015+ logger.warning(
1016+ "context_edit.start=%d > end=%d; not corrected (left to downstream)",
1017+ start,
1018+ end,
1019+ )
1020+ 
1021+ if eff_start == 0:
1022+ start_target = -1 # sentinel: handled below
1023+ else:
1024+ start_target = eff_start - 1
1025+ 
1026+ if start_target == -1:
1027+ bo_start = (0, 0, 0)
1028+ else:
1029+ bo_start = computer(
1030+ messages,
1031+ tools,
1032+ start_target,
1033+ block_size,
1034+ tokenizer,
1035+ boundary="start",
1036+ messages_table=messages_table,
1037+ )
1038+ bo_end = computer(
1039+ messages,
1040+ tools,
1041+ eff_end - 1,
1042+ block_size,
1043+ tokenizer,
1044+ boundary="end",
1045+ messages_table=messages_table,
1046+ )
1047+ return (bo_start, bo_end)
1048+ 
1049+ 
1050+def attach_block_offsets(req_info, messages, tools, tokenizer=None) -> None:
1051+ """Fill server-side block coordinates on ``req_info``'s agent hint.
1052+ 
1053+ ``tokenizer`` is injected by the caller (the scheduler layer owns
1054+ ``TokenizerManager``) so this domain module never imports back into
1055+ ``scheduler.policy``.
1056+ """
1057+ try:
1058+ hint = getattr(req_info, "agent_hint_info", None)
1059+ if hint is None or not getattr(hint, "session_id", None):
1060+ return
1061+ 
1062+ cm = getattr(hint, "context_management", None)
1063+ manage_request = bool(getattr(cm, "manage_request", False)) if cm is not None else False
1064+ if not manage_request:
1065+ return
1066+ 
1067+ is_target_session = False
1068+ if cm is not None and cm.edits:
1069+ agent_hint = req_info.req_data.get("agent_hint") or {}
1070+ cm_dict = agent_hint.setdefault("context_management", {})
tobking
tobkingtobking14 天前

P2:当解析阶段过滤掉任意一个无效 edit 后,len(edits_list) != len(cm.edits),这里会重建为 [dict() ...]。后续循环只写入 block_* / token_* 字段,没有恢复 edit.type、target、start、end,导致下游无法判断 offload/prefetch/evict 及作用范围,编辑语义会丢失。建议重建时从解析后的 edit 模型补齐这些语义字段,并增加部分 edit 被过滤的测试。

likedislike
zengwei
zengwei
14 天前 评论:
1071+ edits_list = cm_dict.get("edits")
1072+ 
1073+ if not isinstance(edits_list, list) or len(edits_list) != len(cm.edits):
1074+ edits_list = [
1075+ {
1076+ "type": edit.type,
1077+ "target": edit.target,
1078+ "start": edit.start,
1079+ "end": edit.end,
1080+ }
1081+ for edit in cm.edits
1082+ ]
1083+ 
1084+ for i, edit in enumerate(cm.edits):
1085+ if edit.target == "session":
1086+ is_target_session = True
1087+ break
1088+ 
1089+ cc = getattr(hint, "cache_control", None)
1090+ kv_conductor_config = ConductorApiClient.coordinator_config.scheduler_config.kv_conductor_config
1091+ block_size = getattr(kv_conductor_config, "block_size", 128)
1092+ if is_target_session and cc is None:
1093+ messages_table = None
1094+ tools_table = None
1095+ else:
1096+ logger.debug(
1097+ "attach_block_offsets: msgs=%d tools=%d",
1098+ len(messages or []),
1099+ len(tools or []),
1100+ )
1101+ 
1102+ calc = get_block_offset_calculator(tokenizer)
1103+ messages, tools = calc.preprocess(messages, tools)
1104+ messages_table = calc.compute_messages(messages, tools, tokenizer)
1105+ if messages_table is None:
1106+ logger.warning(
1107+ "attach_block_offsets: messages offset table is unavailable; "
1108+ "target='messages' edits will fall back to (None, None)."
1109+ )
1110+ 
1111+ tools_table = calc.compute_tools(messages, tools, tokenizer)
1112+ if tools_table is None and tools:
1113+ logger.warning(
1114+ "attach_block_offsets: tools offset table unavailable; "
1115+ "target='tools' edits will fall back to (None, None).",
1116+ )
1117+ 
1118+ if cc is not None:
1119+ bo = compute_block_offset(
1120+ messages=messages,
1121+ tools=tools,
1122+ msg_offset=cc.msg_offset,
1123+ block_size=block_size,
1124+ tokenizer=tokenizer,
1125+ messages_table=messages_table,
1126+ )
1127+ if bo is not None:
1128+ block_idx, intra, token_idx = bo
1129+ cc.block_offset = block_idx
1130+ cc.intra_block_offset = intra
1131+ cc.token_offset = token_idx
1132+ 
1133+ agent_hint = req_info.req_data.get("agent_hint") or {}
1134+ cc_dict = agent_hint.setdefault("cache_control", {})
1135+ cc_dict["block_offset"] = block_idx
1136+ cc_dict["intra_block_offset"] = intra
1137+ cc_dict["token_offset"] = token_idx
1138+ req_info.req_data["agent_hint"] = agent_hint
1139+ 
1140+ if cm is not None and cm.edits:
1141+ agent_hint = req_info.req_data.get("agent_hint") or {}
1142+ cm_dict = agent_hint.setdefault("context_management", {})
1143+ edits_list = cm_dict.get("edits")
1144+ 
1145+ if not isinstance(edits_list, list) or len(edits_list) != len(cm.edits):
1146+ edits_list = [
1147+ {
1148+ "type": edit.type,
1149+ "target": edit.target,
1150+ "start": edit.start,
1151+ "end": edit.end,
1152+ }
1153+ for edit in cm.edits
1154+ ]
1155+ 
1156+ for i, edit in enumerate(cm.edits):
1157+ if edit.target == "session":
1158+ n = len(messages) if isinstance(messages, list) else 0
1159+ eff_end = max(0, n)
1160+ edit.start = 0
1161+ edit.end = eff_end
1162+ if i < len(edits_list) and isinstance(edits_list[i], dict):
1163+ edits_list[i]["start"] = 0
1164+ edits_list[i]["end"] = eff_end
1165+ continue
1166+ 
1167+ bo_start, bo_end = compute_edit_block_offset(
1168+ edit_type=edit.type,
1169+ target=edit.target,
1170+ messages=messages,
1171+ tools=tools,
1172+ start=edit.start,
1173+ end=edit.end,
1174+ block_size=block_size,
1175+ tokenizer=tokenizer,
1176+ messages_table=messages_table,
1177+ tools_table=tools_table,
1178+ )
1179+ if bo_start is not None:
1180+ bs, is_, ts = bo_start
1181+ edit.block_start = bs
1182+ edit.block_intra_start = is_
1183+ edit.start_token = ts
1184+ if bo_end is not None:
1185+ be, ie, te = bo_end
1186+ edit.block_end = be
1187+ edit.block_intra_end = ie
1188+ edit.end_token = te
1189+ if bo_start is not None:
1190+ edits_list[i]["block_start"] = bo_start[0]
1191+ edits_list[i]["block_intra_start"] = bo_start[1]
1192+ edits_list[i]["start_token"] = bo_start[2]
1193+ if bo_end is not None:
1194+ edits_list[i]["block_end"] = bo_end[0]
1195+ edits_list[i]["block_intra_end"] = bo_end[1]
1196+ edits_list[i]["end_token"] = bo_end[2]
1197+ 
1198+ cm_dict["edits"] = edits_list
1199+ req_info.req_data["agent_hint"] = agent_hint
1200+ except Exception as exc: # noqa: BLE001 — fail-open on hot path
1201+ logger.warning("attach_block_offsets failed, skipping: %s", exc)
1202+ return
Mmotor/coordinator/models/request.py+5-1
@@ -1,4 +1,3 @@
1-# -*- coding: utf-8 -*-
2# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.1# Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved.
3# MindIE is licensed under Mulan PSL v2.2# MindIE is licensed under Mulan PSL v2.
4# You can use this software according to the terms and conditions of the Mulan PSL v2.3# You can use this software according to the terms and conditions of the Mulan PSL v2.
@@ -20,6 +19,7 @@ from motor.common.resources.instance import PDRole
20from motor.coordinator.domain.scheduling_constraint import SchedulingConstraint19from motor.coordinator.domain.scheduling_constraint import SchedulingConstraint
21from motor.coordinator.tracer.tracing import TraceObj20from motor.coordinator.tracer.tracing import TraceObj
22from motor.coordinator.models.constants import OpenAIField21from motor.coordinator.models.constants import OpenAIField
22+from motor.coordinator.domain.agent_hint import AgentHintInfo
23 23 
24 24 
25class RequestType(Enum):25class RequestType(Enum):
@@ -99,6 +99,10 @@ class RequestInfo(BaseModel):
99 default=None,99 default=None,
100 description="Internal pin-to-instance constraint (e.g. precision probe); not from client API",100 description="Internal pin-to-instance constraint (e.g. precision probe); not from client API",
101 )101 )
102+ agent_hint_info: AgentHintInfo = Field(
103+ default_factory=AgentHintInfo,
104+ description="Structured information parsed from the request agent_hint for Scheduler available",
105+ )
102 106 
103 def __init__(self, **data):107 def __init__(self, **data):
104 super().__init__(**data)108 super().__init__(**data)
Mmotor/coordinator/router/dispatch.py+10-0
@@ -34,6 +34,10 @@ from motor.coordinator.models.constants import OpenAIField
34from motor.coordinator.models.request import RequestInfo34from 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
37+from motor.coordinator.domain.agent_hint import (
38+ parse_agent_hint,
39+ ensure_minimum_messages_for_session_edits,
40+)
37from motor.coordinator.router.strategies.base import BaseRouter41from motor.coordinator.router.strategies.base import BaseRouter
38from motor.coordinator.router.strategies.pd_hybrid import PDHybridRouter42from motor.coordinator.router.strategies.pd_hybrid import PDHybridRouter
39from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter43from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter
@@ -363,6 +367,11 @@ async def __create_request_info(
363 req_data = request_json.copy()367 req_data = request_json.copy()
364 client_expects_token_ids = bool(request_json.get("return_token_ids", False))368 client_expects_token_ids = bool(request_json.get("return_token_ids", False))
365 369 
370+ ensure_minimum_messages_for_session_edits(request_json, req_data)
371+ agent_hint_info = parse_agent_hint(
372+ request_json,
373+ headers=dict(raw_request.headers),
374+ )
366 return RequestInfo(375 return RequestInfo(
367 req_id=req_id,376 req_id=req_id,
368 req_data=req_data,377 req_data=req_data,
@@ -371,4 +380,5 @@ async def __create_request_info(
371 entry_api=api,380 entry_api=api,
372 client_expects_token_ids=client_expects_token_ids,381 client_expects_token_ids=client_expects_token_ids,
373 client_expects_chat_shape=(OpenAIField.MESSAGES in request_json),382 client_expects_chat_shape=(OpenAIField.MESSAGES in request_json),
383+ agent_hint_info=agent_hint_info,
374 )384 )
Mmotor/coordinator/scheduler/policy/kv_cache_affinity.py+4-1
@@ -14,6 +14,7 @@ from pathlib import Path
14from motor.common.resources.instance import Instance, PDRole14from motor.common.resources.instance import Instance, PDRole
15from motor.common.resources.endpoint import Endpoint15from motor.common.resources.endpoint import Endpoint
16from motor.coordinator.domain import InstanceProvider16from motor.coordinator.domain import InstanceProvider
17+from motor.coordinator.domain.block_offset_translator import attach_block_offsets
17from motor.coordinator.scheduler.policy.base import BaseSchedulingPolicy, WorkloadLedgerMixin18from motor.coordinator.scheduler.policy.base import BaseSchedulingPolicy, WorkloadLedgerMixin
18from motor.config.coordinator import (19from motor.config.coordinator import (
19 CoordinatorConfig,20 CoordinatorConfig,
@@ -215,7 +216,9 @@ class KvCacheAffinityPolicy(WorkloadLedgerMixin, BaseSchedulingPolicy):
215 messages = req_info.req_data.get(OpenAIField.MESSAGES, None)216 messages = req_info.req_data.get(OpenAIField.MESSAGES, None)
216 tools = req_info.req_data.get(OpenAIField.TOOLS, None)217 tools = req_info.req_data.get(OpenAIField.TOOLS, None)
217 if messages is not None:218 if messages is not None:
218- encoded_ids = TokenizerManager().apply_chat_template(messages, tools, req_data=req_info.req_data)219+ tokenizer_manager = TokenizerManager()
220+ encoded_ids = tokenizer_manager.apply_chat_template(messages, tools, req_data=req_info.req_data)
221+ attach_block_offsets(req_info, messages, tools, tokenizer=tokenizer_manager.tokenizer)
219 else:222 else:
220 prompt = req_info.req_data.get(OpenAIField.PROMPT, None)223 prompt = req_info.req_data.get(OpenAIField.PROMPT, None)
221 if prompt is not None:224 if prompt is not None:
Atests/coordinator/domain/test_agent_hint.py+167-0
@@ -0,0 +1,167 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
jason lyu
jason lyujason lyu15 天前

试着用Motor的dev skill约束一下,测试用例行数比代码行都多太多了

likedislike
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of MulanPSL2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Unit tests for agent hint parsing and normalization."""
12+ 
13+import pytest
14+from pydantic import ValidationError
15+ 
16+from motor.coordinator.domain.agent_hint import (
17+ CacheControl,
18+ ContextEdit,
19+ ensure_minimum_messages_for_session_edits,
20+ parse_agent_hint,
21+ parse_manage_request,
22+)
23+ 
24+ 
25+def test_parse_agent_hint_resolves_header_ids_and_preserves_extensions():
26+ """Body IDs take precedence while unknown fields remain available to callers."""
27+ hint = parse_agent_hint(
28+ {"agent_hint": {"session_id": "body-session", "extension": {"enabled": True}}},
29+ headers={"x-session-id": "header-session", "x-parent-session-id": "header-parent"},
30+ )
31+ 
32+ assert hint.session_id == "body-session"
33+ assert hint.parent_session_id == "header-parent"
34+ assert hint.raw_extra == {"extension": {"enabled": True}}
35+ 
36+ 
37+def test_parse_agent_hint_complements_single_session_id():
38+ """A single valid identifier is mirrored to the missing identifier field."""
39+ hint = parse_agent_hint({"agent_hint": {"parent_session_id": "session-1"}})
40+ 
41+ assert (hint.session_id, hint.parent_session_id) == ("session-1", "session-1")
42+ 
43+ 
44+@pytest.mark.parametrize(
45+ "ttl, expected",
46+ [("not-a-number", 300), (0, 60), (7200, 3600), (600, 600)],
47+)
48+def test_cache_control_normalizes_ttl(ttl, expected):
49+ """TTL input is converted to an integer and clamped to the supported range."""
50+ assert CacheControl(ttl=ttl).ttl == expected
51+ 
52+ 
53+def test_parse_agent_hint_drops_invalid_cache_offset_and_server_fields():
54+ """Message offsets outside the request are rejected and server coordinates are ignored."""
55+ hint = parse_agent_hint(
56+ {
57+ "messages": [{"role": "user"}],
58+ "agent_hint": {
59+ "session_id": "s",
60+ "cache_control": {"msg_offset": 2, "block_offset": 99},
61+ },
62+ }
63+ )
64+ 
65+ assert hint.cache_control is None
66+ 
67+ 
68+def test_parse_agent_hint_filters_invalid_context_edits_by_message_and_tool_bounds():
69+ """Only edits with valid half-open ranges for their target are retained."""
70+ hint = parse_agent_hint(
71+ {
72+ "messages": [{"role": "user"}, {"role": "assistant"}],
73+ "tools": [{"type": "function"}],
74+ "agent_hint": {
75+ "session_id": "s",
76+ "context_management": {
77+ "edits": [
78+ {"type": "offload", "start": 0, "end": 1},
79+ {"type": "evict", "target": "tools", "start": 0, "end": 1},
80+ {"type": "prefetch", "start": 2, "end": 2},
81+ {"type": "offload", "start": 0, "end": 2, "target": "tools"},
82+ ]
83+ },
84+ },
85+ }
86+ )
87+ 
88+ assert hint.context_management is not None
89+ assert [(edit.type, edit.target) for edit in hint.context_management.edits] == [
90+ ("offload", "session"),
91+ ("evict", "tools"),
92+ ]
93+ 
94+ 
95+def test_context_edit_requires_supported_type():
96+ """Unsupported context operations fail schema validation rather than being executed."""
97+ with pytest.raises(ValidationError):
98+ ContextEdit(type="delete")
99+ 
100+ 
101+def test_ensure_minimum_messages_injects_session_edit_messages():
102+ """Management session edits receive safe placeholder messages when the body omits them."""
103+ request_json = {"agent_hint": {"context_management": {"manage_request": True, "edits": [{"type": "evict"}]}}}
104+ req_data = {}
105+ 
106+ ensure_minimum_messages_for_session_edits(request_json, req_data)
107+ 
108+ assert len(request_json["messages"]) == 2
109+ assert req_data["messages"] is request_json["messages"]
110+ assert [message["role"] for message in request_json["messages"]] == ["system", "user"]
111+ 
112+ 
113+def test_ensure_minimum_messages_does_not_change_non_management_request():
114+ """Ordinary requests and non-session edits keep their original message payload."""
115+ messages = [{"role": "user", "content": "hello"}]
116+ request_json = {
117+ "messages": messages,
118+ "agent_hint": {"context_management": {"manage_request": False, "edits": [{"type": "evict"}]}},
119+ }
120+ req_data = {"messages": messages}
121+ 
122+ ensure_minimum_messages_for_session_edits(request_json, req_data)
123+ 
124+ assert request_json["messages"] == messages
125+ assert req_data["messages"] == messages
126+ 
127+ 
128+def test_parse_agent_hint_resolves_lowercase_headers_from_real_request():
129+ """Regression: Starlette normalizes header keys to lowercase; parse_agent_hint
130+ must read lowercase keys (matches the real Request.headers path used in dispatch).
131+ """
132+ from starlette.datastructures import Headers
133+ 
134+ # Simulate exactly what dispatch.py passes: dict(raw_request.headers).
135+ # ASGI servers (uvicorn/hypercorn) deliver lowercase header bytes.
136+ raw_request_headers = Headers(raw=[(b"x-session-id", b"req-sess"), (b"x-parent-session-id", b"req-parent")])
137+ headers_dict = dict(raw_request_headers)
138+ 
139+ hint = parse_agent_hint({"agent_hint": {}}, headers=headers_dict)
140+ 
141+ assert hint.session_id == "req-sess"
142+ assert hint.parent_session_id == "req-parent"
143+ 
144+ 
145+@pytest.mark.parametrize(
146+ "value, expected",
147+ [
148+ (True, True),
149+ (False, False),
150+ (1, True),
151+ (0, False),
152+ ("true", True),
153+ ("false", False),
154+ ("True", True),
155+ ("FALSE", False),
156+ ("TrUe", True),
157+ (None, False),
158+ ("", False),
159+ ("yes", False),
160+ (2, False),
161+ (1.5, False),
162+ ([], False),
163+ ],
164+)
165+def test_parse_manage_request_accepts_bool_int_and_string(value, expected):
166+ """JSON bool / 0-1 int / case-insensitive 'true'/'false' parse to bool; everything else falls back to False."""
167+ assert parse_manage_request(value) is expected
Atests/coordinator/domain/test_block_offset_translator.py+363-0
@@ -0,0 +1,363 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
jason lyu
jason lyujason lyu15 天前

license不太对。跑好pre-commit应该会自动帮你修复

likedislike
2+# MindIE is licensed under Mulan PSL v2.
3+# You can use this software according to the terms and conditions of the Mulan PSL v2.
4+# You may obtain a copy of MulanPSL2 at:
5+# http://license.coscl.org.cn/MulanPSL2
6+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
7+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
8+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9+# See the Mulan PSL v2 for more details.
10+ 
11+"""Unit tests for message and tool block-offset translation."""
12+ 
13+from types import SimpleNamespace
14+from unittest.mock import patch
15+ 
16+import pytest
17+ 
18+from motor.coordinator.domain.agent_hint import CacheControl, ContextEdit, ContextManagement, parse_agent_hint
19+from motor.coordinator.domain.block_offset_translator import (
20+ Dsv4BlockOffsetCalculator,
21+ StandardBlockOffsetCalculator,
22+ _align_block_for_op,
23+ attach_block_offsets,
24+ compute_block_offset,
25+ compute_edit_block_offset,
26+ get_block_offset_calculator,
27+)
28+ 
29+ 
30+class MarkerTokenizer:
31+ """Small tokenizer double exposing the standard marker contract."""
32+ 
33+ unk_token_id = -1
34+ 
35+ def convert_tokens_to_ids(self, token):
36+ return {"<|im_start|>": 7}.get(token, -1)
37+ 
38+ def encode(self, text, add_special_tokens=False):
39+ if text == "<|im_start|>":
40+ return [7]
41+ return list(range(len(text)))
42+ 
43+ def apply_chat_template(self, messages, **kwargs):
44+ if kwargs.get("tokenize"):
45+ return [7, 11, 12, 7, 21, 7, 31]
46+ return "prefix"
47+ 
48+ 
49+class Dsv4Tokenizer:
50+ """Tokenizer double whose class name exercises DSV4 calculator selection."""
51+ 
52+ unk_token_id = -1
53+ 
54+ def convert_tokens_to_ids(self, token):
55+ return {"<|User|>": 1, "<|Assistant|>": 2, "<|end▁of▁sentence|>": 3}.get(token, -1)
56+ 
57+ def encode(self, text, add_special_tokens=False):
58+ if text == "<|User|>":
59+ return [1]
60+ if text == "<|Assistant|>":
61+ return [2]
62+ if text == "<|end▁of▁sentence|>":
63+ return [3]
64+ return list(range(len(text)))
65+ 
66+ def apply_chat_template(self, messages, **kwargs):
67+ if kwargs.get("tokenize"):
68+ return [1, 10, 2, 20, 3, 1, 30]
69+ return ""
70+ 
71+ 
72+Dsv4Tokenizer.__name__ = "DSV4Tokenizer"
73+ 
74+ 
75+def test_get_block_offset_calculator_selects_model_family():
76+ """DSV4 wrappers use the marker calculator while ordinary tokenizers use standard logic."""
77+ assert isinstance(get_block_offset_calculator(Dsv4Tokenizer()), Dsv4BlockOffsetCalculator)
78+ assert isinstance(get_block_offset_calculator(MarkerTokenizer()), StandardBlockOffsetCalculator)
79+ 
80+ 
81+@pytest.mark.parametrize(
82+ "token_idx, block_size, policy, boundary, expected",
83+ [
84+ (9, 8, "positive", "start", 1),
85+ (9, 8, "negative", "start", 2),
86+ (9, 8, "negative", "end", 0),
87+ (7, 8, "negative", "end", 0),
88+ (9, 8, "unknown", "start", 1),
89+ (9, 0, "positive", "start", 0),
90+ ],
91+)
92+def test_align_block_for_operation_policies(token_idx, block_size, policy, boundary, expected):
93+ """Alignment policy controls whether partial edge blocks are retained or removed."""
94+ assert _align_block_for_op(token_idx, block_size, policy, boundary) == expected
95+ 
96+ 
97+def test_compute_block_offset_uses_one_based_message_offset():
98+ """The first message offset maps to the first token in the supplied offset table."""
99+ result = compute_block_offset(
100+ messages=[{}, {}],
101+ tools=None,
102+ msg_offset=2,
103+ block_size=8,
104+ tokenizer=None,
105+ messages_table=[5, 17],
106+ )
107+ 
108+ assert result == (2, 1, 17)
109+ 
110+ 
111+def test_compute_edit_block_offset_messages_uses_full_range_sentinels():
112+ """Message edits use token zero for an open range and the selected end message for its end."""
113+ result = compute_edit_block_offset(
114+ edit_type="offload",
115+ messages=[{}, {}, {}],
116+ tools=None,
117+ start=None,
118+ end=2,
119+ block_size=8,
120+ tokenizer=None,
121+ messages_table=[5, 17, 25],
122+ )
123+ 
124+ assert result == ((0, 0, 0), (2, 1, 17))
125+ 
126+ 
127+def test_compute_edit_block_offset_tools_supports_negative_alignment():
128+ """Tool ranges shrink to complete blocks for evict operations."""
129+ result = compute_edit_block_offset(
130+ edit_type="evict",
131+ messages=[{}],
132+ tools=[{"function": {"name": "a"}}, {"function": {"name": "b"}}],
133+ start=1,
134+ end=2,
135+ block_size=8,
136+ tokenizer=None,
137+ target="tools",
138+ tools_table=[3, 16, 23],
139+ )
140+ 
141+ assert result == ((2, 0, 16), (2, 7, 23))
142+ 
143+ 
144+def test_compute_edit_block_offset_returns_none_for_session_target():
145+ """Session-level edits are represented by the whole-session operation, not coordinates."""
146+ assert compute_edit_block_offset("offload", [{}], None, 1, 2, 8, None, target="session", messages_table=[5]) == (
147+ None,
148+ None,
149+ )
150+ 
151+ 
152+def test_attach_block_offsets_populates_model_and_request_coordinates():
153+ """Attached coordinates are written to both parsed models and the outbound request."""
154+ edit = ContextEdit(type="offload", start=1, end=2, target="messages")
155+ hint = SimpleNamespace(
156+ session_id="s",
157+ cache_control=CacheControl(msg_offset=2),
158+ context_management=ContextManagement(manage_request=True, edits=[edit]),
159+ )
160+ req_info = SimpleNamespace(
161+ agent_hint_info=hint,
162+ req_data={
163+ "agent_hint": {
164+ "cache_control": {"msg_offset": 2},
165+ "context_management": {"edits": [{"type": "offload", "start": 1, "end": 2}]},
166+ }
167+ },
168+ )
169+ config = SimpleNamespace(scheduler_config=SimpleNamespace(kv_conductor_config=SimpleNamespace(block_size=8)))
170+ 
171+ with (
172+ patch("motor.coordinator.domain.block_offset_translator.ConductorApiClient.coordinator_config", config),
173+ patch("motor.coordinator.domain.block_offset_translator.get_block_offset_calculator") as get_calculator,
174+ ):
175+ calculator = get_calculator.return_value
176+ calculator.preprocess.return_value = ([{}, {}], None)
177+ calculator.compute_messages.return_value = [5, 17]
178+ calculator.compute_tools.return_value = None
179+ attach_block_offsets(req_info, [{}, {}], None, tokenizer=object())
180+ 
181+ assert hint.cache_control.token_offset == 17
182+ assert hint.cache_control.block_offset == 2
183+ assert edit.start_token == 5
184+ assert edit.end_token == 17
185+ assert req_info.req_data["agent_hint"]["cache_control"]["token_offset"] == 17
186+ assert req_info.req_data["agent_hint"]["context_management"]["edits"][0]["end_token"] == 17
187+ 
188+ 
189+def test_attach_block_offsets_uses_parsed_pydantic_hint():
190+ """Regression: hints built via parse_agent_hint() must populate offsets even though
191+ manage_request lives at context_management.manage_request, not at the top level
192+ on AgentHintInfo. The SimpleNamespace-based positive test alone cannot catch this.
193+ """
194+ request_json = {
195+ "messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}],
196+ "agent_hint": {
197+ "session_id": "s",
198+ "cache_control": {"msg_offset": 2},
199+ "context_management": {
200+ "manage_request": True,
201+ "edits": [{"type": "offload", "start": 1, "end": 2}],
202+ },
203+ },
204+ }
205+ hint = parse_agent_hint(request_json)
206+ 
207+ # Schema guards: manage_request must NOT be hoisted to AgentHintInfo.
208+ assert not hasattr(hint, "manage_request")
209+ assert hint.context_management is not None
210+ assert hint.context_management.manage_request is True
211+ 
212+ req_info = SimpleNamespace(
213+ agent_hint_info=hint,
214+ req_data={
215+ "agent_hint": {
216+ "cache_control": {"msg_offset": 2},
217+ "context_management": {
218+ "manage_request": True,
219+ "edits": [{"type": "offload", "start": 1, "end": 2}],
220+ },
221+ }
222+ },
223+ )
224+ config = SimpleNamespace(scheduler_config=SimpleNamespace(kv_conductor_config=SimpleNamespace(block_size=8)))
225+ 
226+ with (
227+ patch("motor.coordinator.domain.block_offset_translator.ConductorApiClient.coordinator_config", config),
228+ patch("motor.coordinator.domain.block_offset_translator.get_block_offset_calculator") as get_calculator,
229+ ):
230+ calculator = get_calculator.return_value
231+ calculator.preprocess.return_value = ([{}, {}], None)
232+ calculator.compute_messages.return_value = [5, 17]
233+ calculator.compute_tools.return_value = None
234+ attach_block_offsets(req_info, [{}, {}], None, tokenizer=object())
235+ 
236+ # Real Pydantic path now reaches the offset-computation branch.
237+ assert hint.cache_control is not None
238+ assert hint.cache_control.token_offset == 17
239+ assert hint.cache_control.block_offset == 2
240+ assert req_info.req_data["agent_hint"]["cache_control"]["token_offset"] == 17
241+ 
242+ 
243+def test_attach_block_offsets_preserves_edit_semantics_after_partial_filter():
244+ """Partial edit filtering at parse time must not strip type/target/start/end
245+ from surviving edits when attach_block_offsets rebuilds the list.
246+ 
247+ Regression: the rebuild path previously produced dicts with only block_* fields,
248+ silently dropping offload/prefetch/evict type and target — downstream could no
249+ longer dispatch the edit or know its scope.
250+ """
251+ # 2 messages; second edit has end > len(messages) so _validate_edit_indices drops it.
252+ request_json = {
253+ "messages": [{"role": "user"}, {"role": "assistant"}],
254+ "agent_hint": {
255+ "session_id": "s",
256+ "context_management": {
257+ "manage_request": True,
258+ "edits": [
259+ {"type": "offload", "start": 0, "end": 1, "target": "messages"},
260+ {"type": "evict", "start": 0, "end": 99, "target": "messages"},
261+ {"type": "prefetch", "start": 0, "end": 2, "target": "messages"},
262+ ],
263+ },
264+ },
265+ }
266+ hint = parse_agent_hint(request_json)
267+ assert hint.context_management is not None
268+ assert len(hint.context_management.edits) == 2 # Parser kept 2 of 3.
269+ 
270+ # Simulate the dispatch path: req_data carries the RAW (unfiltered) request body
271+ # while cm.edits is filtered. Length mismatch triggers the rebuild branch.
272+ raw_edits = [dict(e) for e in request_json["agent_hint"]["context_management"]["edits"]]
273+ req_info = SimpleNamespace(
274+ agent_hint_info=hint,
275+ req_data={
276+ "agent_hint": {
277+ "context_management": {
278+ "manage_request": True,
279+ "edits": raw_edits,
280+ }
281+ }
282+ },
283+ )
284+ config = SimpleNamespace(scheduler_config=SimpleNamespace(kv_conductor_config=SimpleNamespace(block_size=8)))
285+ 
286+ with (
287+ patch("motor.coordinator.domain.block_offset_translator.ConductorApiClient.coordinator_config", config),
288+ patch("motor.coordinator.domain.block_offset_translator.get_block_offset_calculator") as get_calculator,
289+ ):
290+ calculator = get_calculator.return_value
291+ calculator.preprocess.return_value = ([{}, {}], None)
292+ calculator.compute_messages.return_value = [5, 17]
293+ calculator.compute_tools.return_value = None
294+ attach_block_offsets(req_info, [{}, {}], None, tokenizer=object())
295+ 
296+ out_edits = req_info.req_data["agent_hint"]["context_management"]["edits"]
297+ # Length aligned to surviving edits, NOT the original raw list.
298+ assert len(out_edits) == 2
299+ # Semantic fields preserved on each surviving edit (the regression surface).
300+ assert [(e["type"], e["target"], e["start"], e["end"]) for e in out_edits] == [
301+ ("offload", "messages", 0, 1),
302+ ("prefetch", "messages", 0, 2),
303+ ]
304+ # Block offsets still populated.
305+ assert all("block_start" in e and "end_token" in e for e in out_edits)
306+ 
307+ 
308+def test_attach_block_offsets_skips_non_management_hint():
309+ """Non-management requests are left untouched and do not require tokenizer access."""
310+ edit = ContextEdit(type="offload", start=0, end=1)
311+ req_info = SimpleNamespace(
312+ agent_hint_info=SimpleNamespace(
313+ session_id="s", manage_request=False, cache_control=None, context_management=ContextManagement(edits=[edit])
314+ ),
315+ req_data={},
316+ )
317+ 
318+ attach_block_offsets(req_info, [{}], None)
319+ 
320+ assert edit.block_start is None
321+ 
322+ 
323+def test_dsv4_message_offsets_follow_role_markers():
324+ """DSV4 message boundaries use user, assistant, and EOS markers rather than generic markers."""
325+ calculator = Dsv4BlockOffsetCalculator()
326+ 
327+ assert calculator.compute_messages([{"role": "user"}, {"role": "assistant"}], [], Dsv4Tokenizer()) == [2, 5]
328+ 
329+ 
330+def test_standard_message_offsets_fail_open_without_marker():
331+ """A tokenizer without a recognized chat marker yields no offset table."""
332+ tokenizer = SimpleNamespace(
333+ convert_tokens_to_ids=lambda _: -1, unk_token_id=-1, encode=lambda *_args, **_kwargs: []
334+ )
335+ 
336+ assert StandardBlockOffsetCalculator().compute_messages([{}], None, tokenizer) is None
337+ 
338+ 
339+def test_invalid_block_size_does_not_compute_offsets():
340+ """Non-positive block sizes are rejected before any division occurs."""
341+ assert compute_block_offset([{}], None, 1, 0, None, [1]) is None
342+ assert compute_edit_block_offset("offload", [{}], None, 0, 1, -1, None, messages_table=[1]) is None
343+ 
344+ 
345+@pytest.mark.parametrize("target", ["tools", "session"])
346+def test_edit_target_without_required_data_returns_empty_coordinates(target):
347+ """Missing target data fails open instead of creating forged coordinates."""
348+ assert compute_edit_block_offset("offload", [{}], None, 0, 1, 8, None, target=target, messages_table=[1]) == (
349+ None,
350+ None,
351+ )
352+ 
353+ 
354+@pytest.mark.parametrize("edit_type", ["unknown", "compact"])
355+def test_unknown_edit_type_is_ignored(edit_type):
356+ """Unregistered edit types do not invoke an arbitrary offset computer."""
357+ assert compute_edit_block_offset(edit_type, [{}], None, 0, 1, 8, None, messages_table=[1]) == (None, None)
358+ 
359+ 
360+@pytest.mark.parametrize("tokenizer", [None, SimpleNamespace()])
361+def test_dsv4_tools_offsets_fail_open_without_usable_render(tokenizer):
362+ """Tool translation returns no table when the tokenizer cannot render a complete prompt."""
363+ assert Dsv4BlockOffsetCalculator().compute_tools([{}], [{"function": {"name": "tool"}}], tokenizer) is None
Mtests/coordinator/test_http_server.py+91-0
@@ -1135,6 +1135,97 @@ class TestCoordinatorServerAdvanced:
1135 1135 
1136 assert response.status_code == 400, f"Expected 400 for empty messages, got: {response.status_code}"1136 assert response.status_code == 400, f"Expected 400 for empty messages, got: {response.status_code}"
1137 1137 
1138+ def test_validate_openai_request_empty_messages_manage_false_session_target_rejected(self):
1139+ """Empty messages must be rejected when manage=false even if a session-targeted edit is present."""
1140+ invalid_data = {
1141+ "model": "gpt-3.5-turbo",
1142+ "messages": [],
1143+ "agent_hint": {
1144+ "context_management": {
1145+ "manage_request": False,
1146+ "edits": [{"type": "evict", "target": "session"}],
1147+ }
1148+ },
1149+ }
1150+ inference_client = TestClient(self.coordinator_server.inference_app)
1151+ response = inference_client.post(
1152+ "/v1/chat/completions",
1153+ json=invalid_data,
1154+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"},
1155+ )
1156+ assert response.status_code == 400, (
1157+ f"Expected 400 for empty messages with manage=false + target=session, got: {response.status_code}"
1158+ )
1159+ assert "must be a non-empty array" in response.text
1160+ 
1161+ def test_validate_openai_request_empty_messages_manage_true_messages_target_rejected(self):
1162+ """Empty messages must be rejected when manage=true but target is messages (not session)."""
1163+ invalid_data = {
1164+ "model": "gpt-3.5-turbo",
1165+ "messages": [],
1166+ "agent_hint": {
1167+ "context_management": {
1168+ "manage_request": True,
1169+ "edits": [{"type": "evict", "target": "messages"}],
1170+ }
1171+ },
1172+ }
1173+ inference_client = TestClient(self.coordinator_server.inference_app)
1174+ response = inference_client.post(
1175+ "/v1/chat/completions",
1176+ json=invalid_data,
1177+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"},
1178+ )
1179+ assert response.status_code == 400, (
1180+ f"Expected 400 for empty messages with manage=true + target=messages, got: {response.status_code}"
1181+ )
1182+ assert "must be a non-empty array" in response.text
1183+ 
1184+ def test_validate_openai_request_empty_messages_manage_true_tools_target_rejected(self):
1185+ """Empty messages must be rejected when manage=true but target is tools (not session)."""
1186+ invalid_data = {
1187+ "model": "gpt-3.5-turbo",
1188+ "messages": [],
1189+ "agent_hint": {
1190+ "context_management": {
1191+ "manage_request": True,
1192+ "edits": [{"type": "evict", "target": "tools"}],
1193+ }
1194+ },
1195+ }
1196+ inference_client = TestClient(self.coordinator_server.inference_app)
1197+ response = inference_client.post(
1198+ "/v1/chat/completions",
1199+ json=invalid_data,
1200+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"},
1201+ )
1202+ assert response.status_code == 400, (
1203+ f"Expected 400 for empty messages with manage=true + target=tools, got: {response.status_code}"
1204+ )
1205+ assert "must be a non-empty array" in response.text
1206+ 
1207+ def test_validate_openai_request_empty_messages_manage_true_session_target_allowed(self):
1208+ """Empty messages is the only valid bypass: manage=true AND target=session."""
1209+ data = {
1210+ "model": "gpt-3.5-turbo",
1211+ "messages": [],
1212+ "agent_hint": {
1213+ "context_management": {
1214+ "manage_request": True,
1215+ "edits": [{"type": "evict", "target": "session"}],
1216+ }
1217+ },
1218+ }
1219+ inference_client = TestClient(self.coordinator_server.inference_app)
1220+ response = inference_client.post(
1221+ "/v1/chat/completions",
1222+ json=data,
1223+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"},
1224+ )
1225+ assert response.status_code != 400, (
1226+ f"Empty messages with manage=true + target=session must bypass the non-empty check, got {response.status_code}: {response.text}"
1227+ )
1228+ 
1138 def test_validate_openai_request_invalid_message_format(self):1229 def test_validate_openai_request_invalid_message_format(self):
1139 """Test _validate_openai_request with invalid message format"""1230 """Test _validate_openai_request with invalid message format"""
1140 invalid_data = {1231 invalid_data = {