JRamirez-UAB commited on
Commit
8dfd6cc
·
verified ·
1 Parent(s): 6d5c479

Upload folder using huggingface_hub

Browse files
serving/backend/README.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HybridAgent vLLM backend (T3)
2
+
3
+ vLLM plugin that serves `togethercomputer/HybridAgent-8B-Agentic`
4
+ (Qwen3-8B-Base backbone + HybridLink latent adapter) inside vLLM 0.30 on the
5
+ forked core (`/opt/hybridagent-vllm/vllm`, branch `hybridagent-latent-fork`).
6
+
7
+ - `backend/model.py` — `HybridAgentForCausalLM`: wraps the in-tree
8
+ `Qwen3ForCausalLM` and adds the `HybridLink` MLP. Provides the
9
+ `project_latent(hidden)` hook the fork's `gpu_model_runner` calls to feed
10
+ `R(last_hidden)` as `inputs_embeds` on zero-token latent steps. Backbone
11
+ weights load through the stock Qwen3 path, so an EXL3 checkpoint
12
+ (`quant_method: "exl3"`, OrcaSAQ2 linear layers) loads as a stock Qwen3
13
+ would. `adapter.pt` loads with `torch.load(weights_only=True)` + strict
14
+ `load_state_dict`.
15
+ - `backend/hybridlink.py` — verbatim port of `HybridLink` from
16
+ togethercomputer/HybridAgent (parameter names match `adapter.pt`).
17
+ - `backend/__init__.py` — `register()` entry point
18
+ (`vllm.general_plugins`): maps the checkpoint arch string
19
+ (`Qwen3ForCausalLM`, per the released config.json — re-confirm at T7) to
20
+ `HybridAgentForCausalLM` and arms the OrcaSAQ2 `exl3` quant config when
21
+ installed.
22
+
23
+ Install into the serving venv to activate:
24
+
25
+ pip install -e /opt/hybridagent-vllm/backend
26
+
27
+ Synthetic validation (no model download):
28
+
29
+ /opt/hybridagent-vllm/venv/bin/python /opt/hybridagent-vllm/poc/test_backend_synthetic.py
serving/backend/backend/__init__.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """vLLM plugin: HybridAgent backend (Qwen3 backbone + HybridLink latent MLP).
2
+
3
+ Wired through the `vllm.general_plugins` entry point (see pyproject.toml):
4
+
5
+ [project.entry-points."vllm.general_plugins"]
6
+ hybridagent_backend = "backend:register"
7
+
8
+ vLLM calls `register()` in every process that resolves model classes
9
+ (engine front-end and the registry's inspector subprocesses), so the
10
+ architecture used by HybridAgent checkpoints maps to the latent-capable
11
+ backend everywhere.
12
+
13
+ The arch string is the one in the released model's config.json
14
+ ("architectures": ["Qwen3ForCausalLM"], verified against
15
+ togethercomputer/HybridAgent-8B-Agentic). Re-confirm it against the exact
16
+ checkpoint at T7. Registering under it intentionally shadows the in-tree
17
+ Qwen3 model in this isolated venv: the venv serves HybridAgent only.
18
+ """
19
+ from .model import HybridAgentForCausalLM
20
+
21
+ ARCH = "Qwen3ForCausalLM"
22
+
23
+
24
+ def register() -> None:
25
+ """Entry point: register the HybridAgent backend model architecture."""
26
+ from vllm.logger import init_logger
27
+ from vllm.model_executor.models.registry import ModelRegistry
28
+
29
+ logger = init_logger("vllm.hybridagent")
30
+ ModelRegistry.register_model(ARCH, "backend.model:HybridAgentForCausalLM")
31
+ logger.info(
32
+ "hybridagent backend armed: %s -> HybridAgentForCausalLM "
33
+ "(zero-token latent steps via the project_latent hook)",
34
+ ARCH,
35
+ )
36
+ _arm_exl3(logger)
37
+
38
+
39
+ def _arm_exl3(logger) -> None:
40
+ """Reuse the OrcaSAQ2 'exl3' quantization config when it is installed.
41
+
42
+ The backbone's EXL3 linear layers come from the existing OrcaSAQ2
43
+ plugin (config.json `quant_method: "exl3"`); this backend does not
44
+ reinvent them. Without it, only dense checkpoints can load here.
45
+ """
46
+ try:
47
+ import orcasaq2
48
+ except ImportError:
49
+ logger.warning(
50
+ "hybridagent backend: orcasaq2 not installed in this environment; "
51
+ "EXL3 checkpoints cannot load here (dense checkpoints are unaffected)"
52
+ )
53
+ return
54
+ orcasaq2.register()
55
+
56
+
57
+ __all__ = ["ARCH", "HybridAgentForCausalLM", "register"]
serving/backend/backend/hybridlink.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HybridLink: a lightweight residual adapter that maps a last-layer hidden
2
+ state back into the input-embedding space,
3
+ R(h) = post_ln(h + proj2(gelu(proj1(pre_ln(h)))))
4
+
5
+ Ported verbatim from togethercomputer/HybridAgent (`hybrid_agent/hybridlink.py`
6
+ @ HEAD 1b41cebb). The parameter names match the released checkpoints
7
+ (`adapter.pt`), so the file loads with a strict `load_state_dict`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+
14
+ # Tag stored in `adapter_config.json` next to the HybridLink weights.
15
+ HYBRIDLINK_CONFIG = {"adapter_type": "ln_res_adapter"}
16
+
17
+
18
+ class HybridLink(nn.Module):
19
+ def __init__(self, hidden_size: int) -> None:
20
+ super().__init__()
21
+ self.proj1 = nn.Linear(hidden_size, hidden_size)
22
+ self.act = nn.GELU()
23
+ self.proj2 = nn.Linear(hidden_size, hidden_size)
24
+ self.pre_ln = nn.LayerNorm(hidden_size)
25
+ self.post_ln = nn.LayerNorm(hidden_size)
26
+
27
+ def forward(self, h: torch.Tensor) -> torch.Tensor:
28
+ out = self.proj2(self.act(self.proj1(self.pre_ln(h))))
29
+ return self.post_ln(h + out)
serving/backend/backend/model.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """vLLM backend for HybridAgent: Qwen3 backbone + HybridLink latent adapter.
2
+
3
+ Wraps vLLM's in-tree Qwen3ForCausalLM and adds the HybridLink MLP as a
4
+ submodule. The forked gpu_model_runner (T2) detects latent-capable models
5
+ via the `project_latent` hook and feeds R(last_hidden) as inputs_embeds for
6
+ zero-token latent steps; this class provides that hook and keeps the stock
7
+ Qwen3 forward/logits/weight-loading contract everywhere else.
8
+
9
+ Weight loading:
10
+ * Backbone: the stock Qwen3 path (AutoWeightsLoader over the checkpoint's
11
+ safetensors). Because the in-tree layers are built from
12
+ `vllm_config.quant_config`, an EXL3 checkpoint (config.json
13
+ `quant_method: "exl3"`, served by the OrcaSAQ2 linear layers) loads
14
+ exactly as a stock Qwen3 would -- this backend does not reinvent EXL3.
15
+ * HybridLink: the separate `adapter.pt` file next to the checkpoint,
16
+ loaded with `torch.load(..., weights_only=True)` + strict
17
+ `load_state_dict`, mirroring HybridAgentModel.from_pretrained.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+
24
+ import torch
25
+
26
+ from vllm.config import VllmConfig
27
+ from vllm.logger import init_logger
28
+ from vllm.model_executor.models.qwen3 import Qwen3ForCausalLM
29
+
30
+ from .hybridlink import HYBRIDLINK_CONFIG, HybridLink
31
+
32
+ logger = init_logger(__name__)
33
+
34
+ HYBRIDLINK_WEIGHTS = "adapter.pt"
35
+ HYBRIDLINK_CONFIG_FILE = "adapter_config.json"
36
+
37
+
38
+ class HybridAgentForCausalLM(Qwen3ForCausalLM):
39
+ """Qwen3ForCausalLM + HybridLink, for the forked zero-token latent runner."""
40
+
41
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
42
+ super().__init__(vllm_config=vllm_config, prefix=prefix)
43
+ hidden_size = self.config.hidden_size
44
+ # S4: the HybridLink MLP stays BF16 regardless of backbone dtype.
45
+ self.hybridlink = HybridLink(hidden_size).to(torch.bfloat16)
46
+ self._compute_dtype = vllm_config.model_config.dtype
47
+
48
+ def project_latent(self, hidden: torch.Tensor) -> torch.Tensor:
49
+ """R(h): the hook the forked gpu_model_runner calls for latent steps.
50
+
51
+ Accepts a [D] or [B, D] last-layer hidden state and returns the same
52
+ shape in the model's compute dtype, ready to be consumed as
53
+ inputs_embeds for the zero-token latent position.
54
+ """
55
+ x = hidden
56
+ link_dtype = next(self.hybridlink.parameters()).dtype
57
+ if x.dtype != link_dtype:
58
+ x = x.to(link_dtype)
59
+ # no_grad: the hook is called inside execute_model's inference_mode,
60
+ # but this keeps it correct from any context (LayerNorm on inference
61
+ # tensors fails under an autograd-tracked mode).
62
+ with torch.no_grad():
63
+ out = self.hybridlink(x)
64
+ if out.dtype != self._compute_dtype:
65
+ out = out.to(self._compute_dtype)
66
+ return out
67
+
68
+ def load_weights(self, weights):
69
+ loaded = super().load_weights(weights)
70
+ self._load_hybridlink()
71
+ # The MLP weights come from adapter.pt, not the checkpoint stream;
72
+ # report them as loaded so the loader's completeness tracker passes.
73
+ loaded.update(f"hybridlink.{name}" for name, _ in self.hybridlink.named_parameters())
74
+ return loaded
75
+
76
+ def _load_hybridlink(self) -> None:
77
+ """Strict-load the latent adapter from adapter.pt next to the checkpoint."""
78
+ model_dir = Path(self.vllm_config.model_config.model)
79
+ weights_path = model_dir / HYBRIDLINK_WEIGHTS
80
+ if not weights_path.is_file():
81
+ raise FileNotFoundError(
82
+ f"{HYBRIDLINK_WEIGHTS} not found in {model_dir}; "
83
+ "HybridAgent serving requires the latent adapter"
84
+ )
85
+ config_path = model_dir / HYBRIDLINK_CONFIG_FILE
86
+ if config_path.is_file():
87
+ tag = json.loads(config_path.read_text(encoding="utf-8")).get(
88
+ "adapter_type"
89
+ )
90
+ if tag != HYBRIDLINK_CONFIG["adapter_type"]:
91
+ raise ValueError(
92
+ f"unsupported HybridLink config {tag!r} in {config_path}"
93
+ )
94
+ state = torch.load(str(weights_path), map_location="cpu", weights_only=True)
95
+ self.hybridlink.load_state_dict(state, strict=True)
96
+ logger.info("hybridagent backend: HybridLink loaded from %s", weights_path)
serving/backend/pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hybridagent-vllm-backend"
7
+ version = "0.1.0"
8
+ description = "vLLM backend for HybridAgent: Qwen3ForCausalLM backbone + HybridLink latent MLP (zero-token latent steps)"
9
+ requires-python = ">=3.10"
10
+ # vllm and torch are intentionally not declared: they come from the serving
11
+ # environment, mirroring orcasaq2-kernel.
12
+ dependencies = []
13
+
14
+ [project.entry-points."vllm.general_plugins"]
15
+ hybridagent_backend = "backend:register"
16
+
17
+ [tool.setuptools]
18
+ packages = ["backend"]
serving/fork/v1/core/kv_cache_manager.py ADDED
@@ -0,0 +1,960 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+ import itertools
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Literal, overload
8
+
9
+ from vllm.distributed.kv_events import BlockStored, KVCacheEvent
10
+ from vllm.logger import init_logger
11
+ from vllm.utils.math_utils import cdiv
12
+ from vllm.v1.core.kv_cache_coordinator import (
13
+ HybridKVCacheCoordinator,
14
+ get_kv_cache_coordinator,
15
+ )
16
+ from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
17
+ from vllm.v1.core.kv_cache_utils import KVCacheBlock, KVCacheBlockCopy
18
+ from vllm.v1.core.single_type_kv_cache_manager import MambaManager
19
+ from vllm.v1.kv_cache_interface import (
20
+ AttentionSpec,
21
+ CrossAttentionSpec,
22
+ EncoderOnlyAttentionSpec,
23
+ KVCacheConfig,
24
+ MambaSpec,
25
+ get_kv_cache_spec_kind,
26
+ get_kv_cache_spec_sliding_window,
27
+ )
28
+ from vllm.v1.metrics.stats import PrefixCacheStats
29
+ from vllm.v1.request import Request, RequestStatus
30
+
31
+ logger = init_logger(__name__)
32
+
33
+
34
+ @dataclass
35
+ class KVCacheBlocks:
36
+ """
37
+ The allocation result of KVCacheManager, work as the interface between
38
+ Scheduler and KVCacheManager, to hide KVCacheManager's internal data
39
+ structure from the Scheduler.
40
+ """
41
+
42
+ blocks: tuple[Sequence[KVCacheBlock], ...]
43
+ """
44
+ `blocks[i][j]` refers to the i-th kv_cache_group
45
+ and the j-th block of tokens.We don't use block of
46
+ tokens as the outer dimension because it assumes all
47
+ kv_cache_groups have the same number of blocks, which is true for now but
48
+ will be broken if we want to give different block_size to different
49
+ kv_cache_groups in the future.
50
+
51
+ Each single type KVCacheBlocks could be represented as:
52
+ - list[KVCacheBlock] for more than one KVCacheBlock
53
+ - an empty tuple for requests without KVCacheBlock
54
+ (a precomputed KVCacheBlocks is in KVCacheManager to avoid GC overhead)
55
+ """
56
+
57
+ def __add__(self, other: "KVCacheBlocks") -> "KVCacheBlocks":
58
+ """Adds two KVCacheBlocks instances."""
59
+ return KVCacheBlocks(
60
+ tuple(
61
+ list(itertools.chain(blk1, blk2))
62
+ for blk1, blk2 in zip(self.blocks, other.blocks)
63
+ )
64
+ )
65
+
66
+ @overload
67
+ def get_block_ids(
68
+ self,
69
+ allow_none: Literal[False] = False,
70
+ *,
71
+ group_ids: Sequence[int] | None = None,
72
+ ) -> tuple[list[int], ...]: ...
73
+
74
+ @overload
75
+ def get_block_ids(
76
+ self,
77
+ allow_none: Literal[True] = True,
78
+ *,
79
+ group_ids: Sequence[int] | None = None,
80
+ ) -> tuple[list[int], ...] | None: ...
81
+
82
+ def get_block_ids(
83
+ self,
84
+ allow_none: bool = False,
85
+ *,
86
+ group_ids: Sequence[int] | None = None,
87
+ ) -> tuple[list[int], ...] | None:
88
+ """
89
+ Converts the KVCacheBlocks instance to block_ids.
90
+
91
+ Args:
92
+ allow_none: Return None when every selected group is empty.
93
+ group_ids: KV cache groups to include. Includes all groups by default.
94
+
95
+ Returns:
96
+ tuple[list[int], ...]: A tuple of lists where:
97
+ - the outer tuple corresponds to KV cache groups
98
+ - each inner list contains the block_ids of the blocks in that
99
+ group
100
+ """
101
+ groups = (
102
+ self.blocks
103
+ if group_ids is None
104
+ else tuple(self.blocks[group_id] for group_id in group_ids)
105
+ )
106
+ if allow_none and all(len(group) == 0 for group in groups):
107
+ return None
108
+ return tuple([blk.block_id for blk in group] for group in groups)
109
+
110
+ def get_unhashed_block_ids(self) -> list[int]:
111
+ """Get block_ids of unhashed blocks from KVCacheBlocks instance."""
112
+ assert len(self.blocks) == 1, "Only one group is supported"
113
+ return [block.block_id for block in self.blocks[0] if block.block_hash is None]
114
+
115
+ def get_unhashed_block_ids_all_groups(self) -> list[list[int]]:
116
+ """Get block_ids of unhashed blocks from KVCacheBlocks instance."""
117
+ # Skip padding blocks.
118
+ return [
119
+ [
120
+ block.block_id
121
+ for block in group
122
+ if block.block_hash is None and not block.is_null
123
+ ]
124
+ for group in self.blocks
125
+ ]
126
+
127
+ def new_empty(self) -> "KVCacheBlocks":
128
+ """
129
+ Creates a new KVCacheBlocks instance with no blocks.
130
+ """
131
+ return KVCacheBlocks(tuple(() for _ in range(len(self.blocks))))
132
+
133
+
134
+ class KVCacheManager:
135
+ def __init__(
136
+ self,
137
+ kv_cache_config: KVCacheConfig,
138
+ max_model_len: int,
139
+ scheduler_block_size: int,
140
+ hash_block_size: int,
141
+ max_in_flight_tokens: int | None = None,
142
+ enable_caching: bool = True,
143
+ use_eagle: bool = False,
144
+ num_prefill_lookahead: int = 0,
145
+ log_stats: bool = False,
146
+ enable_kv_cache_events: bool = False,
147
+ dcp_world_size: int = 1,
148
+ pcp_world_size: int = 1,
149
+ metrics_collector: KVCacheMetricsCollector | None = None,
150
+ watermark: float = 0.0,
151
+ enable_mamba_fine_grained_prefix_cache: bool = False,
152
+ ) -> None:
153
+ self.max_model_len = max_model_len
154
+ # When unset, fall back to `max_model_len` so the recycling-aware cap
155
+ # collapses to the prior (uncapped) admission behavior. The scheduler
156
+ # always supplies the real value at runtime.
157
+ if max_in_flight_tokens is None:
158
+ max_in_flight_tokens = max_model_len
159
+
160
+ self.enable_caching = enable_caching
161
+ self.enable_kv_cache_events = enable_kv_cache_events
162
+ self.use_eagle = use_eagle
163
+ self.log_stats = log_stats
164
+ self.metrics_collector = metrics_collector
165
+ # FIXME: make prefix cache stats conditional on log_stats. We still need
166
+ # this comment because when the log stats is enabled there are still
167
+ # potential configs we could expose in the future.
168
+ self.prefix_cache_stats = PrefixCacheStats() if log_stats else None
169
+
170
+ self.coordinator = get_kv_cache_coordinator(
171
+ kv_cache_config=kv_cache_config,
172
+ max_model_len=self.max_model_len,
173
+ max_in_flight_tokens=max_in_flight_tokens,
174
+ use_eagle=self.use_eagle,
175
+ enable_caching=self.enable_caching,
176
+ enable_kv_cache_events=enable_kv_cache_events,
177
+ dcp_world_size=dcp_world_size,
178
+ pcp_world_size=pcp_world_size,
179
+ scheduler_block_size=scheduler_block_size,
180
+ hash_block_size=hash_block_size,
181
+ metrics_collector=self.metrics_collector,
182
+ num_prefill_lookahead=num_prefill_lookahead,
183
+ )
184
+ # One predicate, read by both sides of the feature, so the scheduler
185
+ # cannot end a chunk at a junction the manager would refuse -- a refused
186
+ # junction costs a forward pass and displaces the block-boundary stop.
187
+ # Multi-module MTP is excluded because ``cache_blocks`` then hands the
188
+ # manager ``num_computed - num_reprefillable`` rather than the chunk end.
189
+ self.mamba_fine_grained_prefix_cache = (
190
+ enable_mamba_fine_grained_prefix_cache
191
+ and bool(self.coordinator.eagle_group_ids)
192
+ and self.coordinator.enable_partial_hash_hits
193
+ and self.coordinator.num_reprefillable_tokens == 0
194
+ )
195
+ if self.mamba_fine_grained_prefix_cache:
196
+ for manager in self.coordinator.single_type_managers:
197
+ if isinstance(manager, MambaManager):
198
+ manager.fine_grained_prefix_cache = True
199
+ self.num_kv_cache_groups = len(kv_cache_config.kv_cache_groups)
200
+ self.block_pool = self.coordinator.block_pool
201
+ self.retained_hit_group_ids = tuple(
202
+ manager.kv_cache_group_id
203
+ for manager in self.coordinator.single_type_managers
204
+ if manager.retains_longer_hit
205
+ )
206
+ self.kv_cache_config = kv_cache_config
207
+
208
+ # Watermark: minimum number of KV cache blocks to keep free when
209
+ # admitting waiting/preempted requests, to avoid frequent preemptions.
210
+ assert watermark >= 0.0, "watermark must be non-negative"
211
+ self.watermark_blocks = int(watermark * kv_cache_config.num_blocks)
212
+ self.kv_cache_event_metadata = tuple(
213
+ (
214
+ get_kv_cache_spec_kind(group.kv_cache_spec).value,
215
+ get_kv_cache_spec_sliding_window(group.kv_cache_spec),
216
+ )
217
+ for group in kv_cache_config.kv_cache_groups
218
+ )
219
+
220
+ # Pre-constructed KVCacheBlocks with no blocks, callers should use this
221
+ # via create_kv_cache_blocks instead of creating new ones to avoid GC
222
+ # overhead.
223
+ #
224
+ # We use nested tuples to ensure the empty KVCacheBlocks is immutable.
225
+ self.empty_kv_cache_blocks = KVCacheBlocks(
226
+ tuple(() for _ in range(self.num_kv_cache_groups))
227
+ )
228
+
229
+ @property
230
+ def usage(self) -> float:
231
+ """Get the KV cache usage.
232
+
233
+ Returns:
234
+ The KV cache usage (between 0.0 and 1.0).
235
+ """
236
+ return self.block_pool.get_usage()
237
+
238
+ def make_prefix_cache_stats(self) -> PrefixCacheStats | None:
239
+ """Get (and reset) the prefix cache stats.
240
+
241
+ Returns:
242
+ The current prefix caching stats, or None if logging is disabled.
243
+ """
244
+ if not self.log_stats:
245
+ return None
246
+ stats = self.prefix_cache_stats
247
+ self.prefix_cache_stats = PrefixCacheStats()
248
+ return stats
249
+
250
+ def prefix_cache_lookup_enabled(self, request: Request) -> bool:
251
+ """Whether a local prefix cache lookup may be run for this request."""
252
+ return self.enable_caching and not request.skip_reading_prefix_cache
253
+
254
+ def record_prefix_cache_stats(self, request: Request, num_hits: int) -> None:
255
+ # Don't count a request that skipped the cache lookup.
256
+ if not self.log_stats or not self.prefix_cache_lookup_enabled(request):
257
+ return
258
+ assert self.prefix_cache_stats is not None
259
+ self.prefix_cache_stats.record(
260
+ num_tokens=request.num_tokens,
261
+ num_hits=num_hits,
262
+ preempted=request.num_preemptions > 0,
263
+ )
264
+
265
+ def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int, int]:
266
+ """Get the computed (cached) blocks for the request.
267
+ Note that the computed blocks must be full.
268
+
269
+ Args:
270
+ request: The request to get the computed blocks.
271
+
272
+ Returns:
273
+ A tuple containing:
274
+ - A list of blocks that are computed for the request.
275
+ - The number of computed tokens.
276
+ - ``shared_prefix_boundary``: the block-aligned token position of
277
+ a shared prefix that a sparse-retention group (Mamba / sliding
278
+ window) has not cached yet (Marconi-style APC), or 0 if none.
279
+ Pinned so sparse prefix-cache retention does not drop
280
+ the junction and defeat cross-request reuse.
281
+ """
282
+ # We skip finding the prefix cache hit when prefix caching is
283
+ # disabled or the request is marked as skipping kv cache read
284
+ # (which happens when the request requires prompt logprobs
285
+ # or calls a pooling model with all pooling).
286
+ if not self.prefix_cache_lookup_enabled(request):
287
+ return self.empty_kv_cache_blocks, 0, 0
288
+
289
+ # NOTE: When all tokens hit the cache, we must recompute the last token
290
+ # to obtain logits. Thus, set max_cache_hit_length to prompt_length - 1.
291
+ # This can trigger recomputation of an entire block, rather than just
292
+ # the single last token, because allocate_slots() requires
293
+ # num_computed_tokens to be block-size aligned. Removing this limitation
294
+ # could slightly improve performance in the future.
295
+ max_cache_hit_length = request.num_tokens - 1
296
+ computed_blocks, num_new_computed_tokens, num_uncached = (
297
+ self.coordinator.find_longest_cache_hit(
298
+ request.block_hashes, max_cache_hit_length
299
+ )
300
+ )
301
+
302
+ # When kv_cache_report_mode is "full", emit BlockStored events
303
+ # for the reused prefix cache blocks so that external consumers
304
+ # (e.g. gateway) can learn about them.
305
+ if (
306
+ num_new_computed_tokens > 0
307
+ and self.enable_kv_cache_events
308
+ and getattr(request, "kv_cache_report_mode", "incremental") == "full"
309
+ ):
310
+ self.coordinator.emit_cached_block_events(request, computed_blocks)
311
+
312
+ # The junction to pin is where the lagging sparse-retention group stops
313
+ # (``num_new_computed_tokens``) plus the uncached shared prefix -- i.e.
314
+ # the longest single-group hit. Sub-block gaps are left to the mask,
315
+ # which floors to the alignment boundary (a no-op there).
316
+ shared_prefix_boundary = (
317
+ num_new_computed_tokens + num_uncached if num_uncached else 0
318
+ )
319
+
320
+ blocks = self.create_kv_cache_blocks(computed_blocks)
321
+ return blocks, num_new_computed_tokens, shared_prefix_boundary
322
+
323
+ def get_computed_blocks_for_connector(
324
+ self, request: Request
325
+ ) -> tuple[KVCacheBlocks, int, int, bool]:
326
+ """Local prefix-cache lookup for a request scheduled with a KV connector.
327
+
328
+ Hybrid (Mamba + full-attention) models can have per-group prefix hits
329
+ diverge under block pressure: the full-attention tail may be evicted
330
+ while a deeper Mamba state survives, or vice versa. Report the
331
+ full-attention hit as the local prefix - the connector transfers the
332
+ remaining suffix and the Mamba state is transferred unconditionally by
333
+ nixl's ``_apply_prefix_caching`` - and flag when that hit ran deeper
334
+ than a lagging group. Such a hit only has a valid Mamba state at its
335
+ boundary if the connector supplies it, so the caller must fall back to
336
+ ``get_computed_blocks`` to reconcile when no external tokens are found.
337
+
338
+ Non-hybrid models and already-convergent hits use ``get_computed_blocks``.
339
+
340
+ Returns:
341
+ The ``get_computed_blocks`` triple (blocks, number of local computed
342
+ tokens, shared-prefix boundary) plus ``hit_diverged``.
343
+ """
344
+ coordinator = self.coordinator
345
+ if not (
346
+ self.kv_cache_config.has_mamba_layers
347
+ and isinstance(coordinator, HybridKVCacheCoordinator)
348
+ and coordinator.full_attention_group_id is not None
349
+ ):
350
+ return *self.get_computed_blocks(request), False
351
+
352
+ if not self.prefix_cache_lookup_enabled(request):
353
+ return self.empty_kv_cache_blocks, 0, 0, False
354
+
355
+ fa_group_id = coordinator.full_attention_group_id
356
+ computed, per_group_hits = coordinator.find_longest_cache_hit_per_group(
357
+ request.block_hashes, request.num_tokens - 1
358
+ )
359
+ if any(hit > per_group_hits[fa_group_id] for hit in per_group_hits):
360
+ # A lagging group hit deeper than full attention means its
361
+ # full-attention blocks were evicted; use the reconciled boundary
362
+ # that every group agrees on.
363
+ return *self.get_computed_blocks(request), False
364
+
365
+ num_local = per_group_hits[fa_group_id]
366
+ blocks = self.create_kv_cache_blocks(computed)
367
+ # Per-group lookups do not detect an uncached shared prefix (boundary 0).
368
+ return blocks, num_local, 0, min(per_group_hits) < num_local
369
+
370
+ def allocate_slots(
371
+ self,
372
+ request: Request,
373
+ num_new_tokens: int,
374
+ num_new_computed_tokens: int = 0,
375
+ new_computed_blocks: KVCacheBlocks | None = None,
376
+ num_lookahead_tokens: int = 0,
377
+ num_external_computed_tokens: int = 0,
378
+ delay_cache_blocks: bool = False,
379
+ num_encoder_tokens: int = 0,
380
+ full_sequence_must_fit: bool = False,
381
+ reserved_blocks: int = 0,
382
+ has_scheduled_reqs: bool = True,
383
+ num_latent_positions: int = 0, # FORK: zero-token latent KV positions
384
+ ) -> KVCacheBlocks | None:
385
+ """Add slots for a request with new tokens to append.
386
+
387
+ Args:
388
+ request: The request to allocate slots.
389
+ num_new_tokens: The number of new tokens to be allocated and computed.
390
+ num_new_computed_tokens: The number of new computed tokens just
391
+ hitting the prefix caching, excluding external tokens.
392
+ new_computed_blocks: The cached blocks for the above new computed
393
+ tokens, grouped as a tuple by kv cache groups.
394
+ num_lookahead_tokens: The number of speculative tokens to allocate.
395
+ This is used by spec decode proposers with kv-cache such
396
+ as eagle.
397
+ num_external_computed_tokens: The number of tokens that their
398
+ KV caches are not cached by vLLM but cached by the connector.
399
+ delay_cache_blocks: Whether to skip caching the blocks. This is
400
+ used by P/D when allocating blocks used in a KV transfer
401
+ which will complete in a future step.
402
+ num_encoder_tokens: The number of encoder tokens to allocate for
403
+ cross-attention in encoder-decoder models(e.g., Whisper).
404
+ For decoder-only models, this should be 0.
405
+ full_sequence_must_fit: Only allocate blocks if the KV cache has enough
406
+ free blocks to hold the full sequence, accounting for prefix cache hits
407
+ and sliding window. Used as an admission gate to prevent over-admitting
408
+ requests when chunked prefill would otherwise only check the first chunk
409
+ reserved_blocks: Number of free blocks that must be left available for
410
+ other in-flight sequences to complete. The actual allocation is only
411
+ made if it fits within (free blocks - reserved_blocks). Used to gate
412
+ async KV-connector loads so their initial allocation cannot consume
413
+ blocks an already in-flight (prefilling) sequence is relying on.
414
+ has_scheduled_reqs: Whether any requests are already scheduled to run
415
+ this step, controls whether watermark is applied.
416
+ num_latent_positions: FORK: number of zero-token latent KV positions
417
+ to allocate for this step (HybridAgent latent steps consume a KV
418
+ position without a token id).
419
+
420
+ Blocks layout:
421
+ ```
422
+ ----------------------------------------------------------------------
423
+ | < comp > | < new_comp > | < ext_comp > | < new > | < lookahead > |
424
+ ----------------------------------------------------------------------
425
+ | < to be computed > |
426
+ ----------------------------------------------------------------------
427
+ | < to be allocated > |
428
+ ----------------------------------------------------------------------
429
+ | < to be cached (roughly, |
430
+ | details below)> |
431
+ ----------------------------------------------------------------------
432
+ | Prefix-cached tokens from either vLLM |
433
+ | or connector. Can be safely removed if |
434
+ | they are outside sliding window. |
435
+ ----------------------------------------------------------------------
436
+ | < cached by vLLM > | not cached by |
437
+ | vLLM, but |
438
+ | ref_cnt | ref_cnt not | cached by |
439
+ | increased| increased yet| connector |
440
+ ----------------------------------------------------------------------
441
+ ```
442
+
443
+ Abbrivations:
444
+
445
+ ```
446
+ comp = request.num_computed_tokens
447
+ new_comp = num_new_computed_tokens
448
+ = len(new_computed_blocks) * block_size
449
+ ext_comp = num_external_computed_tokens, cached by the connector
450
+ new = num_new_tokens, including unverified draft tokens
451
+ lookahead = num_lookahead_tokens
452
+ ```
453
+
454
+ NOTE: for new tokens which include both verified and unverified draft
455
+ tokens, we only cache the verified tokens (by capping the number at
456
+ `request.num_tokens`).
457
+
458
+ The allocation has three stages:
459
+ - Free unnecessary blocks in `comp` and check
460
+ if we have sufficient free blocks (return None if not).
461
+ - Handle prefix tokens (`comp + new_comp + ext_comp`):
462
+ - Free unnecessary blocks (e.g. outside sliding window)
463
+ - Allocate new blocks for `ext_comp` tokens inside
464
+ sliding window
465
+ - Allocate new blocks for tokens to be computed (`new + lookahead`)
466
+
467
+ Returns:
468
+ A list of new allocated blocks.
469
+ """
470
+ # When loading KV data asynchronously, we may have zero new tokens to
471
+ # compute while still allocating slots for externally computed tokens.
472
+ # FORK: a zero-token latent step (num_latent_positions > 0) also
473
+ # allocates slots without any new token id.
474
+ if (
475
+ num_new_tokens == 0
476
+ and num_external_computed_tokens == 0
477
+ and num_latent_positions == 0
478
+ ):
479
+ raise ValueError(
480
+ "num_new_tokens must be greater than 0 when there are no "
481
+ "external computed tokens"
482
+ )
483
+
484
+ if new_computed_blocks is not None:
485
+ new_computed_block_list = new_computed_blocks.blocks
486
+ if self.retained_hit_group_ids:
487
+ # Only retain the prefix covered by the local and external hits.
488
+ reused = num_new_computed_tokens + num_external_computed_tokens
489
+ groups = list(new_computed_block_list)
490
+ for group_id in self.retained_hit_group_ids:
491
+ manager = self.coordinator.single_type_managers[group_id]
492
+ groups[group_id] = groups[group_id][
493
+ : cdiv(reused, manager.block_size)
494
+ ]
495
+ new_computed_block_list = tuple(groups)
496
+ else:
497
+ new_computed_block_list = self.empty_kv_cache_blocks.blocks
498
+
499
+ # The number of computed tokens is the number of computed tokens plus
500
+ # the new prefix caching hits
501
+ num_local_computed_tokens = (
502
+ request.num_computed_tokens + num_new_computed_tokens
503
+ )
504
+ total_computed_tokens = min(
505
+ num_local_computed_tokens + num_external_computed_tokens,
506
+ self.max_model_len,
507
+ )
508
+
509
+ watermark_blocks = 0
510
+ # The watermark is applied to waiting/preempted requests only, and only
511
+ # when there's at least one request already scheduled.
512
+ if has_scheduled_reqs and request.status in (
513
+ RequestStatus.WAITING,
514
+ RequestStatus.PREEMPTED,
515
+ ):
516
+ watermark_blocks = self.watermark_blocks
517
+
518
+ if full_sequence_must_fit:
519
+ # First check and fail if the full request sequence won't fit.
520
+ full_num_tokens = min(request.num_tokens, self.max_model_len)
521
+
522
+ num_blocks_to_allocate = self.coordinator.get_num_blocks_to_allocate(
523
+ request_id=request.request_id,
524
+ num_tokens=full_num_tokens,
525
+ new_computed_blocks=new_computed_block_list,
526
+ num_encoder_tokens=num_encoder_tokens,
527
+ total_computed_tokens=total_computed_tokens,
528
+ num_local_computed_tokens=num_local_computed_tokens,
529
+ num_tokens_main_model=full_num_tokens,
530
+ apply_admission_cap=True,
531
+ )
532
+ required_blocks = num_blocks_to_allocate + watermark_blocks
533
+ if required_blocks > self.block_pool.get_num_free_blocks():
534
+ return None
535
+
536
+ # FORK: latent positions are KV positions too, so the block table must
537
+ # extend to cover them even though no token id exists for them.
538
+ num_tokens_main_model = (
539
+ total_computed_tokens + num_new_tokens + num_latent_positions
540
+ )
541
+ num_tokens_need_slot = min(
542
+ num_tokens_main_model + num_lookahead_tokens, self.max_model_len
543
+ )
544
+
545
+ # Free the blocks that are skipped during the attention computation
546
+ # (e.g., tokens outside the sliding window).
547
+ # We can do this even if we cannot schedule this request due to
548
+ # insufficient free blocks.
549
+ # Should call this function before allocating new blocks to reduce
550
+ # the number of evicted blocks.
551
+ # Free on the processed-token basis: in-flight steps' attention windows
552
+ # still read blocks below the optimistic boundary, and rejected spec
553
+ # tokens can roll it back.
554
+ self.coordinator.remove_skipped_blocks(
555
+ request.request_id,
556
+ max(0, total_computed_tokens - request.num_in_flight_tokens),
557
+ num_prompt_tokens=request.num_prompt_tokens,
558
+ )
559
+
560
+ num_blocks_to_allocate = self.coordinator.get_num_blocks_to_allocate(
561
+ request_id=request.request_id,
562
+ num_tokens=num_tokens_need_slot,
563
+ new_computed_blocks=new_computed_block_list,
564
+ num_encoder_tokens=num_encoder_tokens,
565
+ total_computed_tokens=num_local_computed_tokens
566
+ + num_external_computed_tokens,
567
+ num_local_computed_tokens=num_local_computed_tokens,
568
+ num_tokens_main_model=num_tokens_main_model,
569
+ )
570
+
571
+ # Keep `reserved_blocks` free for other in-flight sequences, and an
572
+ # additional watermark of headroom for waiting/preempted admissions.
573
+ available_blocks = self.block_pool.get_num_free_blocks() - reserved_blocks
574
+ required_blocks = num_blocks_to_allocate + watermark_blocks
575
+ if required_blocks > available_blocks:
576
+ # Cannot allocate new blocks
577
+ return None
578
+
579
+ if (
580
+ new_computed_block_list is not self.empty_kv_cache_blocks.blocks
581
+ or num_external_computed_tokens > 0
582
+ ):
583
+ # Append the new computed blocks to the request blocks until now to
584
+ # avoid the case where the new blocks cannot be allocated.
585
+ self.coordinator.allocate_new_computed_blocks(
586
+ request_id=request.request_id,
587
+ new_computed_blocks=new_computed_block_list,
588
+ num_local_computed_tokens=num_local_computed_tokens,
589
+ num_external_computed_tokens=num_external_computed_tokens,
590
+ )
591
+
592
+ new_blocks = self.coordinator.allocate_new_blocks(
593
+ request.request_id,
594
+ num_tokens_need_slot,
595
+ num_tokens_main_model,
596
+ num_encoder_tokens,
597
+ )
598
+
599
+ # P/D: delay caching blocks if we have to recv from
600
+ # remote. Update state for locally cached blocks.
601
+ if delay_cache_blocks:
602
+ return self.create_kv_cache_blocks(new_blocks)
603
+
604
+ # NOTE(woosuk): We want to commit (cache) up to num_local_computed_tokens
605
+ # + num_external_computed_tokens + num_new_tokens, but must exclude
606
+ # "non-committable" tokens (e.g., draft tokens that could be rejected).
607
+ # Therefore, we cap the number at `request.num_tokens`, ensuring only
608
+ # "finalized" tokens are cached.
609
+ # FORK: never cache beyond the frozen cacheable prefix. A latent
610
+ # block's KV derives from an injected embedding, not a token id, so it
611
+ # must never enter the prefix cache (and BlockPool.cache_full_blocks
612
+ # would index past request.block_hashes if it did).
613
+ num_tokens_to_cache = min(
614
+ total_computed_tokens + num_new_tokens,
615
+ request.num_tokens,
616
+ request.kv_cacheable_prefix or self.max_model_len,
617
+ )
618
+ self.coordinator.cache_blocks(request, num_tokens_to_cache)
619
+
620
+ return self.create_kv_cache_blocks(new_blocks)
621
+
622
+ def free(self, request: Request) -> None:
623
+ """Free the blocks allocated for the request.
624
+ We free the blocks in reverse order so that the tail blocks are evicted
625
+ first when caching is enabled.
626
+
627
+ Args:
628
+ request: The request to free the blocks.
629
+ """
630
+ self.coordinator.free(request.request_id)
631
+
632
+ def remove_skipped_blocks(
633
+ self,
634
+ request_id: str,
635
+ processed_computed_tokens: int,
636
+ num_prompt_tokens: int | None = None,
637
+ ) -> None:
638
+ """Remove the blocks that are no longer needed from `blocks` and replace
639
+ the removed blocks with null_block.
640
+
641
+ Args:
642
+ request_id: The request ID.
643
+ processed_computed_tokens: Computed-token prefix length covering
644
+ fully processed and committed tokens only (safe to free).
645
+ num_prompt_tokens: Optional prompt length for R-SWA gap eviction.
646
+ """
647
+ self.coordinator.remove_skipped_blocks(
648
+ request_id, processed_computed_tokens, num_prompt_tokens
649
+ )
650
+
651
+ def pop_blocks_for_free(self, request: Request) -> list[KVCacheBlock]:
652
+ """Pop the request's bookkeeping and return its blocks without
653
+ returning them to the block pool. The caller must eventually free
654
+ them in reverse order (so that tail blocks are evicted first).
655
+
656
+ Args:
657
+ request: The request to pop the blocks for.
658
+
659
+ Returns:
660
+ The request's blocks in allocation order.
661
+ """
662
+ return self.coordinator.pop_blocks_for_free(request.request_id)
663
+
664
+ def evict_blocks(self, block_ids: set[int]) -> None:
665
+ """evict blocks from the prefix cache by their block IDs.
666
+
667
+ Args:
668
+ block_ids: Set of block IDs to evict from cache.
669
+ """
670
+ self.block_pool.evict_blocks(block_ids)
671
+
672
+ def reset_prefix_cache(self) -> bool:
673
+ """Reset prefix cache. This function may be used in RLHF
674
+ flows to invalidate prefix caching after the weights are updated,
675
+ or used for resetting prefix caching status for benchmarking.
676
+
677
+ Returns:
678
+ bool: True if the prefix cache is successfully reset,
679
+ False otherwise.
680
+ """
681
+ if not self.coordinator.reset_prefix_cache():
682
+ return False
683
+ if self.log_stats:
684
+ assert self.prefix_cache_stats is not None
685
+ self.prefix_cache_stats.reset = True
686
+ return True
687
+
688
+ def get_num_common_prefix_blocks(self, running_request_id: str) -> list[int]:
689
+ """Calculate the number of common prefix blocks for each kv cache group.
690
+
691
+ The function selects a running request and iterates through its blocks.
692
+ A block is considered a common prefix block if ALL requests with
693
+ allocated KV cache share it (i.e., ref_cnt equals the number of entries
694
+ in req_to_blocks).
695
+
696
+ NOTE(woosuk): The number of requests with allocated KV cache is **greater
697
+ than or equal to** the number of requests scheduled in the current step.
698
+ This is because having allocated KV cache only indicates that:
699
+ 1. The request has not yet finished, and
700
+ 2. The request holds its blocks unfreed.
701
+
702
+ While all scheduled requests must have allocated KV cache, the inverse
703
+ is not necessarily true. There may be requests with allocated KV cache
704
+ that are not scheduled in the current step.
705
+
706
+ This can result in an edge case where the number of common prefix blocks
707
+ is 0, even though all scheduled requests share a common prefix. This
708
+ occurs because there may be unscheduled requests that do not share the
709
+ common prefix. Currently, this case cannot be easily detected, so the
710
+ function returns 0 in such cases.
711
+
712
+ Args:
713
+ running_request_id: The request ID of any running request, used to
714
+ identify the common prefix blocks.
715
+
716
+ Returns:
717
+ list[int]: The number of common prefix blocks for each kv cache
718
+ group.
719
+ """
720
+ return self.coordinator.get_num_common_prefix_blocks(running_request_id)
721
+
722
+ def take_events(self) -> list[KVCacheEvent]:
723
+ """Take the KV cache events from the block pool.
724
+
725
+ Returns:
726
+ A list of KV cache events.
727
+ """
728
+ events = self.block_pool.take_events()
729
+ for event in events:
730
+ if not isinstance(event, BlockStored):
731
+ continue
732
+ if event.group_idx is None:
733
+ continue
734
+ if event.group_idx < 0 or event.group_idx >= len(
735
+ self.kv_cache_event_metadata
736
+ ):
737
+ logger.warning(
738
+ "Group index `%s` not in KV cache metadata", event.group_idx
739
+ )
740
+ continue
741
+ # Annotate here so BlockPool can keep emitting structural cache
742
+ # events without owning semantic KV cache spec metadata.
743
+ kind, sliding_window = self.kv_cache_event_metadata[event.group_idx]
744
+ event.kv_cache_spec_kind = kind
745
+ event.kv_cache_spec_sliding_window = sliding_window
746
+ return events
747
+
748
+ def get_blocks(self, request_id: str) -> KVCacheBlocks:
749
+ """Get the blocks of a request."""
750
+ return self.create_kv_cache_blocks(self.coordinator.get_blocks(request_id))
751
+
752
+ def get_block_ids(self, request_id: str) -> tuple[list[int], ...]:
753
+ """Get the block ids of a request."""
754
+ return self.get_blocks(request_id).get_block_ids()
755
+
756
+ def get_block_ids_for_computed_tokens(
757
+ self,
758
+ request_id: str,
759
+ num_computed_tokens: int,
760
+ ) -> tuple[list[int], ...]:
761
+ """Get block ids covering the request's computed tokens."""
762
+ block_ids = self.get_block_ids(request_id)
763
+ clipped_block_ids: list[list[int]] = []
764
+ for group, ids in zip(self.kv_cache_config.kv_cache_groups, block_ids):
765
+ spec = group.kv_cache_spec
766
+ if not isinstance(spec, AttentionSpec) or isinstance(
767
+ spec, (CrossAttentionSpec, EncoderOnlyAttentionSpec)
768
+ ):
769
+ clipped_block_ids.append(ids)
770
+ continue
771
+
772
+ num_valid_blocks = cdiv(num_computed_tokens, spec.block_size)
773
+ clipped_block_ids.append(ids[:num_valid_blocks])
774
+ return tuple(clipped_block_ids)
775
+
776
+ def estimate_cached_tokens(self, request: Request) -> int:
777
+ """Estimate the number of tokens cached by the request."""
778
+ cached_tokens: int | None = None
779
+ for group, blocks in zip(
780
+ self.kv_cache_config.kv_cache_groups,
781
+ self.get_blocks(request.request_id).blocks,
782
+ ):
783
+ if not group.kv_cache_spec.prefix_cacheable:
784
+ continue
785
+ if isinstance(
786
+ group.kv_cache_spec,
787
+ (CrossAttentionSpec, EncoderOnlyAttentionSpec),
788
+ ):
789
+ # Cross-attention and encoder-only groups are not prefix cached.
790
+ continue
791
+
792
+ group_cached_tokens = 0
793
+ for block in blocks:
794
+ group_cached_tokens = max(
795
+ group_cached_tokens,
796
+ block.block_hash_num_tokens or 0,
797
+ )
798
+
799
+ cached_tokens = (
800
+ group_cached_tokens
801
+ if cached_tokens is None
802
+ else min(cached_tokens, group_cached_tokens)
803
+ )
804
+
805
+ return cached_tokens or 0
806
+
807
+ def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
808
+ """Cache the blocks for the request, if enabled.
809
+
810
+ Args:
811
+ request: The request to cache the blocks.
812
+ num_computed_tokens: The number of computed tokens, including tokens
813
+ that are already cached and tokens to be cached.
814
+ """
815
+ if self.enable_caching:
816
+ self.coordinator.cache_blocks(request, num_computed_tokens)
817
+
818
+ def create_kv_cache_blocks(
819
+ self, blocks: tuple[list[KVCacheBlock], ...]
820
+ ) -> KVCacheBlocks:
821
+ # Only create new KVCacheBlocks for non-empty blocks
822
+ return KVCacheBlocks(blocks) if any(blocks) else self.empty_kv_cache_blocks
823
+
824
+ def truncate_computed_blocks(
825
+ self, blocks: KVCacheBlocks, num_computed_tokens: int
826
+ ) -> KVCacheBlocks:
827
+ """Return a lookup-result view truncated at an aligned token endpoint.
828
+
829
+ An external hit can supply the final Mamba state even when the local
830
+ Mamba group ends before this endpoint. Other groups must cover it.
831
+ Pure slicing: refcounts are untouched and ``blocks`` is not mutated.
832
+ """
833
+ truncated: list[list[KVCacheBlock]] = []
834
+ for group_blocks, manager, group in zip(
835
+ blocks.blocks,
836
+ self.coordinator.single_type_managers,
837
+ self.kv_cache_config.kv_cache_groups,
838
+ strict=True,
839
+ ):
840
+ if not group.kv_cache_spec.prefix_cacheable:
841
+ # Scratch groups (e.g. the CSA compressor ring) hold a fixed
842
+ # block covering no token range, so a lookup result never
843
+ # carries computed blocks for them and their block size does
844
+ # not divide the endpoint.
845
+ assert not group_blocks
846
+ truncated.append([])
847
+ continue
848
+ assert num_computed_tokens % manager.block_size == 0
849
+ num_blocks = num_computed_tokens // manager.block_size
850
+ if isinstance(group.kv_cache_spec, MambaSpec):
851
+ num_blocks = min(num_blocks, len(group_blocks))
852
+ else:
853
+ assert num_blocks <= len(group_blocks)
854
+ truncated.append(list(group_blocks[:num_blocks]))
855
+ return self.create_kv_cache_blocks(tuple(truncated))
856
+
857
+ def take_new_block_ids(self) -> list[int]:
858
+ """Drain and return new attention block IDs for zeroing."""
859
+ ids: list[int] = []
860
+ for mgr in self.coordinator.single_type_managers:
861
+ ids.extend(mgr.take_new_block_ids())
862
+ return ids
863
+
864
+ def get_zeroing_block_ids_in_range(
865
+ self, request_id: str, start_token: int, end_token: int
866
+ ) -> list[int]:
867
+ """The request's block ids covering [start_token, end_token), from
868
+ the groups whose new blocks are zeroed by the worker."""
869
+ ids: list[int] = []
870
+ for mgr in self.coordinator.single_type_managers:
871
+ if mgr.records_new_block_ids:
872
+ start_idx = start_token // mgr.block_size
873
+ end_idx = cdiv(end_token, mgr.block_size)
874
+ blocks = mgr.req_to_blocks[request_id]
875
+ ids.extend(blk.block_id for blk in blocks[start_idx:end_idx])
876
+ return ids
877
+
878
+ def record_blocks_for_zeroing(self, request_id: str, start_token: int) -> None:
879
+ """Re-record the request's blocks from start_token onwards for
880
+ zeroing, e.g. blocks a failed async KV load left unwritten.
881
+
882
+ start_token must be block-aligned: zeroing a partially-valid block
883
+ would wipe its valid prefix.
884
+ """
885
+ for mgr in self.coordinator.single_type_managers:
886
+ if mgr.records_new_block_ids:
887
+ assert start_token % mgr.block_size == 0
888
+ start_idx = start_token // mgr.block_size
889
+ blocks = mgr.req_to_blocks[request_id]
890
+ mgr.new_block_ids.extend(blk.block_id for blk in blocks[start_idx:])
891
+
892
+ def take_kv_cache_block_copies(
893
+ self,
894
+ ) -> tuple[list[KVCacheBlockCopy], list[KVCacheBlock]]:
895
+ """Drain pending copies and return their retained endpoints."""
896
+ pending_copies: list[tuple[KVCacheBlock, KVCacheBlock]] = []
897
+ for mgr in self.coordinator.single_type_managers:
898
+ pending_copies.extend(mgr.take_pending_cow_copies())
899
+ copies = [
900
+ KVCacheBlockCopy(
901
+ src_block_id=source_block.block_id,
902
+ dst_block_id=cow_block.block_id,
903
+ )
904
+ for source_block, cow_block in pending_copies
905
+ ]
906
+ retained_blocks = [block for pair in pending_copies for block in pair]
907
+ return copies, retained_blocks
908
+
909
+ def take_boundary_state_offloads(
910
+ self,
911
+ ) -> dict[str, list[tuple[int, int, int]]]:
912
+ """Drain this step's boundary-state hand-offs for a KV connector.
913
+
914
+ Returns ``{request_id: [(group_id, block_id, boundary_tokens), ...]}``
915
+ for mamba "align" boundary states: the
916
+ request's committed boundary-state snapshots and, on a sub-block partial
917
+ hit, the CoW copy of its last-prompt-boundary state. Only mamba "align"
918
+ groups contribute; empty otherwise. A connector reads the referenced
919
+ blocks — never resolving them positionally — and offloads them so a
920
+ later request can hit that prefix.
921
+ """
922
+ offloads: dict[str, list[tuple[int, int, int]]] = {}
923
+ for mgr in self.coordinator.single_type_managers:
924
+ for (
925
+ req_id,
926
+ group_id,
927
+ block,
928
+ boundary_tokens,
929
+ ) in mgr.take_pending_boundary_state_offloads():
930
+ offloads.setdefault(req_id, []).append(
931
+ (group_id, block.block_id, boundary_tokens)
932
+ )
933
+ return offloads
934
+
935
+ def finalize_partial_tail_offloads(
936
+ self, request: Request
937
+ ) -> list[tuple[int, int, int]]:
938
+ """Consume safe producer partial tails when a request finishes.
939
+
940
+ A mamba align table block is a valid boundary source only if no later
941
+ token was forwarded. The connector pins and queues the exact table
942
+ block before request cleanup, then releases the pin when every worker
943
+ reports the store job complete.
944
+ """
945
+ offloads: list[tuple[int, int, int]] = []
946
+ for mgr in self.coordinator.single_type_managers:
947
+ finalized = mgr.finalize_partial_tail_offload(
948
+ request.request_id,
949
+ request.num_computed_tokens,
950
+ request.num_in_flight_tokens,
951
+ )
952
+ if finalized is None:
953
+ continue
954
+ group_id, block, boundary_tokens = finalized
955
+ offloads.append((group_id, block.block_id, boundary_tokens))
956
+ return offloads
957
+
958
+ def new_step_starts(self) -> None:
959
+ """Notify the coordinator that a new step is starting."""
960
+ self.coordinator.new_step_starts()
serving/fork/v1/core/sched/output.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from functools import cached_property
7
+ from typing import TYPE_CHECKING
8
+
9
+ from vllm.config.ec_manager_config import EncoderCacheManagerMetadata
10
+ from vllm.multimodal.utils import strip_covered_mm_data
11
+
12
+ if TYPE_CHECKING:
13
+ import numpy as np
14
+ import numpy.typing as npt
15
+ import torch
16
+
17
+ from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorMetadata
18
+ from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
19
+ from vllm.lora.request import LoRARequest
20
+ from vllm.multimodal.inputs import MultiModalFeatureSpec
21
+ from vllm.pooling_params import PoolingParams
22
+ from vllm.sampling_params import SamplingParams
23
+ from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
24
+ from vllm.v1.request import Request
25
+ else:
26
+ ECConnectorMetadata = object
27
+ KVConnectorMetadata = object
28
+ KVCacheBlockCopy = object
29
+ LoRARequest = object
30
+ MultiModalFeatureSpec = object
31
+ PoolingParams = object
32
+ SamplingParams = object
33
+ Request = object
34
+
35
+
36
+ @dataclass
37
+ class NewRequestData:
38
+ req_id: str
39
+ prompt_token_ids: list[int] | None
40
+ mm_features: list[MultiModalFeatureSpec]
41
+ sampling_params: SamplingParams | None
42
+ pooling_params: PoolingParams | None
43
+ block_ids: tuple[list[int], ...]
44
+ num_computed_tokens: int
45
+ lora_request: LoRARequest | None
46
+ prompt_embeds: "torch.Tensor | None" = None
47
+ prompt_is_token_ids: list[bool] | None = None
48
+
49
+ # Only used for v2 model runner.
50
+ prefill_token_ids: list[int] | None = None
51
+
52
+ @classmethod
53
+ def from_request(
54
+ cls,
55
+ request: Request,
56
+ block_ids: tuple[list[int], ...],
57
+ prefill_token_ids: list[int] | None = None,
58
+ uses_mrope: bool = False,
59
+ ) -> "NewRequestData":
60
+ return cls(
61
+ req_id=request.request_id,
62
+ prompt_token_ids=request.prompt_token_ids,
63
+ mm_features=strip_covered_mm_data(
64
+ request.mm_features,
65
+ request.num_computed_tokens,
66
+ uses_mrope=uses_mrope,
67
+ ),
68
+ sampling_params=request.sampling_params,
69
+ pooling_params=request.pooling_params,
70
+ block_ids=block_ids,
71
+ num_computed_tokens=request.num_computed_tokens,
72
+ lora_request=request.lora_request,
73
+ prompt_embeds=request.prompt_embeds,
74
+ prompt_is_token_ids=request.prompt_is_token_ids,
75
+ prefill_token_ids=prefill_token_ids,
76
+ )
77
+
78
+ @property
79
+ def prompt_len(self) -> int:
80
+ if self.prompt_token_ids is not None:
81
+ return len(self.prompt_token_ids)
82
+ if self.prompt_embeds is not None:
83
+ return self.prompt_embeds.shape[0]
84
+ return 0
85
+
86
+ def __repr__(self) -> str:
87
+ prompt_embeds_shape = (
88
+ self.prompt_embeds.shape if self.prompt_embeds is not None else None
89
+ )
90
+ return (
91
+ f"NewRequestData("
92
+ f"req_id={self.req_id},"
93
+ f"prompt_token_ids={self.prompt_token_ids},"
94
+ f"prefill_token_ids={self.prefill_token_ids},"
95
+ f"mm_features={self.mm_features},"
96
+ f"sampling_params={self.sampling_params},"
97
+ f"block_ids={self.block_ids},"
98
+ f"num_computed_tokens={self.num_computed_tokens},"
99
+ f"lora_request={self.lora_request},"
100
+ f"prompt_embeds_shape={prompt_embeds_shape}"
101
+ ")"
102
+ )
103
+
104
+ # Version of __repr__ with the prompt data obfuscated
105
+ def anon_repr(self) -> str:
106
+ prompt_token_ids_len = (
107
+ len(self.prompt_token_ids) if self.prompt_token_ids is not None else None
108
+ )
109
+ prompt_embeds_shape = (
110
+ self.prompt_embeds.shape if self.prompt_embeds is not None else None
111
+ )
112
+ prefill_token_ids_len = (
113
+ len(self.prefill_token_ids) if self.prefill_token_ids is not None else None
114
+ )
115
+ return (
116
+ f"NewRequestData("
117
+ f"req_id={self.req_id},"
118
+ f"prompt_token_ids_len={prompt_token_ids_len},"
119
+ f"prefill_token_ids_len={prefill_token_ids_len},"
120
+ f"mm_features={self.mm_features},"
121
+ f"sampling_params={self.sampling_params},"
122
+ f"block_ids={self.block_ids},"
123
+ f"num_computed_tokens={self.num_computed_tokens},"
124
+ f"lora_request={self.lora_request},"
125
+ f"prompt_embeds_shape={prompt_embeds_shape}"
126
+ ")"
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class CachedRequestData:
132
+ req_ids: list[str]
133
+ # For request ids not in resumed_req_ids, new_block_ids will be appended to
134
+ # the request's block IDs. For those in the set, new_block_ids will be used as the
135
+ # request's block IDs instead of appending to the existing block IDs.
136
+ resumed_req_ids: set[str]
137
+ # NOTE(woosuk): new_token_ids is only used for pipeline parallelism.
138
+ # When PP is not used, new_token_ids will be empty.
139
+ new_token_ids: list[list[int]]
140
+ # MRV1-only: For requests not scheduled in the last step, propagate the token ids
141
+ # to the connector. Won't contain requests scheduled in the prior step.
142
+ all_token_ids: dict[str, list[int]]
143
+ new_block_ids: list[tuple[list[int], ...] | None]
144
+ num_computed_tokens: list[int]
145
+ num_output_tokens: list[int]
146
+
147
+ # Version of dataclass repr with token IDs obfuscated.
148
+ def anon_repr(self) -> str:
149
+ new_token_ids_lens = [len(toks) for toks in self.new_token_ids]
150
+ all_token_ids_lens = {
151
+ req_id: len(toks) for req_id, toks in self.all_token_ids.items()
152
+ }
153
+ return (
154
+ f"CachedRequestData("
155
+ f"req_ids={self.req_ids},"
156
+ f"resumed_req_ids={self.resumed_req_ids},"
157
+ f"new_token_ids_lens={new_token_ids_lens},"
158
+ f"all_token_ids_lens={all_token_ids_lens},"
159
+ f"new_block_ids={self.new_block_ids},"
160
+ f"num_computed_tokens={self.num_computed_tokens},"
161
+ f"num_output_tokens={self.num_output_tokens}"
162
+ f")"
163
+ )
164
+
165
+ def __repr__(self) -> str:
166
+ return self.anon_repr()
167
+
168
+ @property
169
+ def num_reqs(self) -> int:
170
+ return len(self.req_ids)
171
+
172
+ @cached_property
173
+ def _req_id_to_num_output_tokens(self) -> dict[str, int]:
174
+ """Cache mapping of req_id to num_output_tokens for O(1) lookup.
175
+
176
+ This cached property is safe because CachedRequestData instances
177
+ are created fresh each scheduling iteration and not mutated during
178
+ computation of iteration details.
179
+ """
180
+ return dict(zip(self.req_ids, self.num_output_tokens))
181
+
182
+ def is_context_phase(self, req_id: str) -> bool:
183
+ num_output_tokens = self._req_id_to_num_output_tokens.get(req_id)
184
+ return num_output_tokens is not None and num_output_tokens == 0
185
+
186
+ @classmethod
187
+ def make_empty(cls) -> "CachedRequestData":
188
+ return cls(
189
+ req_ids=[],
190
+ resumed_req_ids=set(),
191
+ new_token_ids=[],
192
+ all_token_ids={},
193
+ new_block_ids=[],
194
+ num_computed_tokens=[],
195
+ num_output_tokens=[],
196
+ )
197
+
198
+
199
+ @dataclass
200
+ class ScheduledEncoderInputStats:
201
+ """Stats for encoder inputs scheduled in one iteration."""
202
+
203
+ num_inputs: int = 0
204
+ output_tokens: int = 0
205
+
206
+
207
+ @dataclass
208
+ class KVConnectorBlockState:
209
+ """Scheduler-local block state offered to a producer-side KV connector."""
210
+
211
+ # Requests scheduled this step and requests with a boundary-state hand-off.
212
+ req_ids: set[str]
213
+ # Resolve on access to avoid copying tables the connector never reads.
214
+ resolve_block_ids: Callable[[str], tuple[list[int], ...]]
215
+ # Exact Mamba "align" boundary-state hand-offs.
216
+ boundary_state_offloads: dict[str, list[tuple[int, int, int]]]
217
+
218
+ def get_block_ids(self, req_id: str) -> tuple[list[int], ...] | None:
219
+ if req_id not in self.req_ids:
220
+ return None
221
+ return self.resolve_block_ids(req_id)
222
+
223
+
224
+ @dataclass
225
+ class SchedulerOutput:
226
+ # list of the requests that are scheduled for the first time.
227
+ # We cache the request's data in each worker process, so that we don't
228
+ # need to re-send it every scheduling step.
229
+ scheduled_new_reqs: list[NewRequestData]
230
+ # list of the requests that have been scheduled before.
231
+ # Since the request's data is already cached in the worker processes,
232
+ # we only send the diff to minimize the communication cost.
233
+ scheduled_cached_reqs: CachedRequestData
234
+
235
+ # req_id -> num_scheduled_tokens
236
+ # Number of tokens scheduled for each request.
237
+ num_scheduled_tokens: dict[str, int]
238
+ # Total number of tokens scheduled for all requests.
239
+ # Equal to sum(num_scheduled_tokens.values())
240
+ total_num_scheduled_tokens: int
241
+ # req_id -> spec_token_ids
242
+ # If a request does not have any spec decode tokens, it will not be
243
+ # included in the dictionary.
244
+ scheduled_spec_decode_tokens: dict[str, list[int]]
245
+ # req_id -> encoder input indices that need processing.
246
+ # E.g., if a request has [0, 1], it could mean the vision encoder needs
247
+ # to process that the request's 0-th and 1-th images in the current step.
248
+ scheduled_encoder_inputs: dict[str, list[int]]
249
+ # Number of common prefix blocks for all requests in each KV cache group.
250
+ # This can be used for cascade attention.
251
+ num_common_prefix_blocks: list[int]
252
+
253
+ # Request IDs that are finished in between the previous and the current
254
+ # steps. This is used to notify the workers about the finished requests
255
+ # so that they can free the cached states for those requests.
256
+ finished_req_ids: set[str]
257
+ # list of mm_hash strings associated with the encoder outputs to be
258
+ # freed from the encoder cache.
259
+ free_encoder_mm_hashes: list[str]
260
+ # FORK: req_id -> number of zero-token latent KV positions scheduled this
261
+ # step (HybridAgent latent steps consume a KV position without a token id).
262
+ # Empty when no latent step is scheduled.
263
+ latent_steps: dict[str, int] = field(default_factory=dict)
264
+ # FORK: req_id -> 1 for the latent ENTRY step: a pending-latent request
265
+ # that still has a pending token id (the `<latent>` token position). The
266
+ # model runner drops that step's sample so the position becomes a latent
267
+ # KV position fed by project_latent(prev_hidden).
268
+ latent_entry_steps: dict[str, int] = field(default_factory=dict)
269
+
270
+ scheduled_encoder_input_stats: ScheduledEncoderInputStats | None = None
271
+
272
+ # Request IDs that are preempted in this step.
273
+ # Only used for v2 model runner.
274
+ preempted_req_ids: set[str] | None = None
275
+
276
+ # Whether any of the scheduled requests use structured output.
277
+ # Set only in async scheduling case.
278
+ has_structured_output_requests: bool = False
279
+
280
+ # Whether the scheduled requests have all the output tokens they
281
+ # need to perform grammar bitmask computation.
282
+ pending_structured_output_tokens: bool = False
283
+
284
+ # Used for adjusting acceptance rate calculation.
285
+ num_invalid_spec_tokens: dict[str, int] | None = None
286
+
287
+ # KV Cache Connector metadata.
288
+ kv_connector_metadata: KVConnectorMetadata | None = None
289
+
290
+ # Whether any scheduled request consumes KV that the connector loads
291
+ # synchronously during this step (load_async=False).
292
+ has_sync_kv_loads: bool = False
293
+
294
+ # EC Cache Connector metadata
295
+ ec_connector_metadata: ECConnectorMetadata | None = None
296
+ # EC Cache Manager metadata
297
+ ec_manager_metadata: EncoderCacheManagerMetadata | None = None
298
+ # Block IDs freshly allocated from the pool during this scheduling step.
299
+ # The worker zeros the corresponding GPU memory before the blocks are used,
300
+ # preventing stale NaN/data from corrupting attention or SSM computation.
301
+ new_block_ids_to_zero: list[int] | None = None
302
+
303
+ # CoW copies to apply after zeroing new blocks and before forward.
304
+ kv_cache_block_copies: list[KVCacheBlockCopy] | None = None
305
+
306
+ # Complete block-table rows that replace incrementally appended block IDs.
307
+ block_table_updates: dict[str, tuple[list[int], ...]] | None = None
308
+
309
+ # Scheduler-local; always None by the time this reaches a worker.
310
+ kv_connector_block_state: KVConnectorBlockState | None = None
311
+
312
+ # Dynamic speculative decoding: optimal K chosen by scheduler.
313
+ # Number of spec tokens to schedule for the next step.
314
+ num_spec_tokens_to_schedule: int = 0
315
+
316
+ @classmethod
317
+ def make_empty(cls) -> "SchedulerOutput":
318
+ return cls(
319
+ scheduled_new_reqs=[],
320
+ scheduled_cached_reqs=CachedRequestData.make_empty(),
321
+ num_scheduled_tokens={},
322
+ total_num_scheduled_tokens=0,
323
+ scheduled_spec_decode_tokens={},
324
+ scheduled_encoder_inputs={},
325
+ num_common_prefix_blocks=[],
326
+ finished_req_ids=set(),
327
+ free_encoder_mm_hashes=[],
328
+ )
329
+
330
+
331
+ @dataclass
332
+ class GrammarOutput:
333
+ # ids of structured output requests.
334
+ structured_output_request_ids: list[str]
335
+ # Bitmask ordered as structured_output_request_ids.
336
+ grammar_bitmask: "npt.NDArray[np.int32]"
serving/fork/v1/core/sched/scheduler.py ADDED
The diff for this file is too large to render. See raw diff
 
serving/fork/v1/request.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+ import enum
5
+ import time
6
+ from collections import deque
7
+ from collections.abc import Callable, Mapping
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import torch
12
+
13
+ from vllm.multimodal.inputs import MultiModalFeatureSpec
14
+ from vllm.pooling_params import PoolingParams
15
+ from vllm.sampling_params import SamplingParams
16
+ from vllm.utils import length_from_prompt_token_ids_or_embeds
17
+ from vllm.v1.engine import (
18
+ EngineCoreEvent,
19
+ EngineCoreEventType,
20
+ EngineCoreRequest,
21
+ FinishReason,
22
+ )
23
+ from vllm.v1.metrics.stats import PrefillStats, RequestSpecDecodeMetrics
24
+ from vllm.v1.structured_output.request import StructuredOutputRequest
25
+ from vllm.v1.utils import ConstantList
26
+
27
+ if TYPE_CHECKING:
28
+ from vllm.lora.request import LoRARequest
29
+ from vllm.v1.core.kv_cache_utils import BlockHash
30
+
31
+
32
+ @dataclass
33
+ class StreamingUpdate:
34
+ """Lightweight data for streaming session continuation.
35
+
36
+ Contains only the fields needed to update an existing streaming session
37
+ with new input data.
38
+ """
39
+
40
+ mm_features: list[MultiModalFeatureSpec] | None
41
+ prompt_token_ids: list[int] | None
42
+ max_tokens: int
43
+ arrival_time: float
44
+ sampling_params: SamplingParams | None
45
+
46
+ @classmethod
47
+ def from_request(cls, request: "Request") -> "StreamingUpdate | None":
48
+ if not request.resumable:
49
+ return None
50
+ return cls(
51
+ mm_features=request.mm_features,
52
+ prompt_token_ids=request.prompt_token_ids,
53
+ max_tokens=request.max_tokens,
54
+ arrival_time=request.arrival_time,
55
+ sampling_params=request.sampling_params,
56
+ )
57
+
58
+
59
+ class Request:
60
+ def __init__(
61
+ self,
62
+ request_id: str,
63
+ prompt_token_ids: list[int] | None,
64
+ sampling_params: SamplingParams | None,
65
+ pooling_params: PoolingParams | None,
66
+ client_index: int = 0,
67
+ arrival_time: float | None = None,
68
+ prompt_embeds: torch.Tensor | None = None,
69
+ prompt_is_token_ids: list[bool] | None = None,
70
+ mm_features: list[MultiModalFeatureSpec] | None = None,
71
+ lora_request: "LoRARequest | None" = None,
72
+ cache_salt: str | None = None,
73
+ priority: int = 0,
74
+ trace_headers: Mapping[str, str] | None = None,
75
+ block_hasher: Callable[["Request"], list["BlockHash"]] | None = None,
76
+ resumable: bool = False,
77
+ session_id: str | None = None,
78
+ reasoning_ended: bool | None = None,
79
+ reasoning_parser_kwargs: dict[str, Any] | None = None,
80
+ abort_immediately: bool = False,
81
+ ) -> None:
82
+ self.request_id = request_id
83
+ self.client_index = client_index
84
+ self.priority = priority
85
+ self.sampling_params = sampling_params
86
+ self.pooling_params = pooling_params
87
+ self.lora_request = lora_request
88
+ self.structured_output_request = StructuredOutputRequest.from_sampling_params(
89
+ sampling_params
90
+ )
91
+ if self.structured_output_request is not None:
92
+ self.structured_output_request.reasoning_ended = reasoning_ended
93
+ self.structured_output_request.reasoning_parser_kwargs = (
94
+ reasoning_parser_kwargs
95
+ )
96
+ self.arrival_time = arrival_time if arrival_time is not None else time.time()
97
+
98
+ self.status = RequestStatus.WAITING
99
+ self.events: list[EngineCoreEvent] = []
100
+ self.stop_reason: int | str | None = None
101
+
102
+ # P/D: Connector-specific KV transfer parameters.
103
+ self.kv_transfer_params: dict[str, Any] | None = None
104
+ # E/P/D: Connector-specific encoder-cache transfer parameters.
105
+ self.ec_transfer_params: dict[str, Any] | None = None
106
+
107
+ if pooling_params is not None:
108
+ # Pooling models.
109
+ self.max_tokens = 1
110
+ elif sampling_params is not None:
111
+ # Generative models.
112
+ assert sampling_params.max_tokens is not None
113
+ self.max_tokens = sampling_params.max_tokens
114
+ if self.structured_output_request is not None:
115
+ self.status = RequestStatus.WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR
116
+
117
+ if sampling_params.extra_args is not None:
118
+ self.kv_transfer_params = sampling_params.extra_args.get(
119
+ "kv_transfer_params"
120
+ )
121
+ self.ec_transfer_params = sampling_params.extra_args.get(
122
+ "ec_transfer_params"
123
+ )
124
+ self.kv_cache_report_mode = sampling_params.extra_args.get(
125
+ "kv_cache_report_mode", "incremental"
126
+ )
127
+ else:
128
+ self.kv_cache_report_mode = "incremental"
129
+ else:
130
+ raise ValueError("sampling_params and pooling_params can't both be unset")
131
+
132
+ self.prompt_token_ids = prompt_token_ids
133
+ self.prompt_embeds = prompt_embeds
134
+ # Per-position mask used in mixed-mode (chat completion with
135
+ # prompt_embeds). `None` except when both `prompt_token_ids` and
136
+ # `prompt_embeds` are set and their positions are interleaved.
137
+ self.prompt_is_token_ids = prompt_is_token_ids
138
+ # Cache per-block prompt-embed hashes to avoid rehashing the same
139
+ # tensor slices when generating extra keys.
140
+ self._prompt_embeds_per_block_hashes: dict[tuple[int, int], bytes] = {}
141
+ self.num_prompt_tokens = length_from_prompt_token_ids_or_embeds(
142
+ prompt_token_ids, prompt_embeds
143
+ )
144
+ self._output_token_ids: list[int] = []
145
+ if self.prompt_token_ids is None:
146
+ self._all_token_ids: list[int] = [0] * self.num_prompt_tokens
147
+ elif self.prompt_is_token_ids is None:
148
+ self._all_token_ids = self.prompt_token_ids.copy()
149
+ else:
150
+ # Mixed-mode prompt: positions covered by prompt_embeds hold a sentinel
151
+ # special token id that may lie outside the embedding. Zero them, matching
152
+ # the no-token-ids case above, so embedding gathers over these placeholder
153
+ # ids stay in bounds; the actual inputs come from prompt_embeds.
154
+ self._all_token_ids = [
155
+ t if is_tok else 0
156
+ for t, is_tok in zip(self.prompt_token_ids, self.prompt_is_token_ids)
157
+ ]
158
+
159
+ # Used in async scheduling.
160
+ self.num_output_placeholders = 0
161
+ # Tokens of output in flight when the request was preempted: delivered
162
+ # on return, but must not mutate the reset counters.
163
+ self.num_stale_output_tokens = 0
164
+ # Drop the stale output instead, for same-step preempt + resume
165
+ # (reset_prefix_cache).
166
+ self.drop_stale_output = False
167
+
168
+ # Tokens of steps whose output is not yet processed (async scheduling
169
+ # and PP run ahead of the GPU); `num_computed_tokens` counts them
170
+ # optimistically.
171
+ self.num_in_flight_tokens = 0
172
+
173
+ # V2+PP+async: Enforces `pp_size` cadence between same-request decode steps
174
+ # so the worker's broadcast slot ring stays consistent.
175
+ self.next_decode_eligible_step = 0
176
+
177
+ # Seq of the most recent step this request was scheduled in; fences
178
+ # deferred block freeing (see Scheduler._free_request_blocks).
179
+ self.last_sched_seq = 0
180
+
181
+ self.spec_token_ids: list[int] = []
182
+ self.num_computed_tokens = 0
183
+ # FORK: zero-token latent steps (HybridAgent). Latent KV positions
184
+ # filled so far; invariant:
185
+ # num_computed_tokens == real token KV positions + num_latent_computed.
186
+ self.num_latent_computed = 0
187
+ # FORK: frozen to num_computed_tokens at the first latent step; latent
188
+ # blocks are allocated but never prefix-cached (their KV derives from
189
+ # an injected embedding, not a token id).
190
+ self.kv_cacheable_prefix: int | None = None
191
+ # FORK: set by the state machine (T4/T5) to mark the next step as a
192
+ # zero-token latent step.
193
+ self.pending_latent = False
194
+ self.cache_salt: str | None = cache_salt
195
+
196
+ # Multi-modal related
197
+ self.mm_features = mm_features or []
198
+
199
+ # Read-only views
200
+ # Prevent directly appending to these lists since
201
+ # they should also be updated simultaneously.
202
+ self.output_token_ids = ConstantList(self._output_token_ids)
203
+ self.all_token_ids = ConstantList(self._all_token_ids)
204
+ # trace_headers
205
+ self.trace_headers = trace_headers
206
+ self.session_id = session_id
207
+
208
+ # True if this request is scheduled as a non-final prefill chunk.
209
+ self.is_prefill_chunk = False
210
+
211
+ # Block-aligned token position of a proven shared prefix worth pinning
212
+ # in the (sparse) prefix cache; 0 means none. Set at admission for
213
+ # hybrid/Mamba models when a shared prefix is detected (Marconi-style).
214
+ self.shared_prefix_boundary = 0
215
+
216
+ # The number of NaNs in logits. A value greater than 0
217
+ # indicates that the output is corrupted
218
+ self.num_nans_in_logits = 0
219
+
220
+ # The number of times this request has been preempted by the scheduler.
221
+ self.num_preemptions = 0
222
+
223
+ self.prefill_stats: PrefillStats | None = PrefillStats()
224
+
225
+ # Per-request speculative-decoding acceptance accumulator. Populated by
226
+ # the scheduler when --per-request-spec-decode-metrics is set (eagerly on
227
+ # add_request, then observed each verify step); stays None otherwise.
228
+ self.spec_decode_metrics: RequestSpecDecodeMetrics | None = None
229
+
230
+ self.block_hashes: list[BlockHash] = []
231
+ # Store the block hasher without binding self to avoid creating a
232
+ # reference cycle (Request -> partial -> Request) that prevents
233
+ # immediate garbage collection via reference counting.
234
+ self._block_hasher: Callable[[Request], list[BlockHash]] | None = block_hasher
235
+ self.update_block_hashes()
236
+
237
+ self.skip_reading_prefix_cache = self.get_skip_reading_prefix_cache()
238
+
239
+ # Used for streaming
240
+ self.resumable = resumable
241
+ # None entry in the queue means finished.
242
+ self.streaming_queue: deque[StreamingUpdate | None] | None = None
243
+
244
+ # If True, request should be aborted immediately after being added to
245
+ # the scheduler so the connector's request_finished hook runs.
246
+ self.abort_immediately = abort_immediately
247
+
248
+ @classmethod
249
+ def from_engine_core_request(
250
+ cls,
251
+ request: EngineCoreRequest,
252
+ block_hasher: Callable[["Request"], list["BlockHash"]] | None,
253
+ ) -> "Request":
254
+ return cls(
255
+ request_id=request.request_id,
256
+ client_index=request.client_index,
257
+ prompt_token_ids=request.prompt_token_ids,
258
+ prompt_embeds=request.prompt_embeds,
259
+ prompt_is_token_ids=request.prompt_is_token_ids,
260
+ mm_features=request.mm_features,
261
+ sampling_params=request.sampling_params,
262
+ pooling_params=request.pooling_params,
263
+ arrival_time=request.arrival_time,
264
+ lora_request=request.lora_request,
265
+ cache_salt=request.cache_salt,
266
+ priority=request.priority,
267
+ trace_headers=request.trace_headers,
268
+ block_hasher=block_hasher,
269
+ resumable=request.resumable,
270
+ session_id=request.session_id,
271
+ reasoning_ended=request.reasoning_ended,
272
+ reasoning_parser_kwargs=request.reasoning_parser_kwargs,
273
+ abort_immediately=request.abort_immediately,
274
+ )
275
+
276
+ def append_output_token_ids(
277
+ self,
278
+ token_ids: int | list[int],
279
+ ) -> None:
280
+ if isinstance(token_ids, int):
281
+ self._output_token_ids.append(token_ids)
282
+ self._all_token_ids.append(token_ids)
283
+ else:
284
+ self._output_token_ids.extend(token_ids)
285
+ self._all_token_ids.extend(token_ids)
286
+
287
+ self.update_block_hashes()
288
+
289
+ def update_block_hashes(self) -> None:
290
+ """Compute block hashes for any new full blocks and append them."""
291
+ if self._block_hasher is not None:
292
+ self.block_hashes.extend(self._block_hasher(self))
293
+
294
+ @property
295
+ def use_structured_output(self) -> bool:
296
+ return self.structured_output_request is not None
297
+
298
+ @property
299
+ def num_tokens(self) -> int:
300
+ return len(self._all_token_ids)
301
+
302
+ @property
303
+ def num_tokens_with_spec(self) -> int:
304
+ return len(self._all_token_ids) + len(self.spec_token_ids)
305
+
306
+ @property
307
+ def num_output_tokens(self) -> int:
308
+ return len(self._output_token_ids)
309
+
310
+ @property
311
+ def num_encoder_inputs(self) -> int:
312
+ return len(self.mm_features)
313
+
314
+ @property
315
+ def has_encoder_inputs(self) -> bool:
316
+ return self.num_encoder_inputs > 0
317
+
318
+ def get_skip_reading_prefix_cache(self) -> bool:
319
+ if (
320
+ self.sampling_params is not None
321
+ and self.sampling_params.skip_reading_prefix_cache is not None
322
+ ):
323
+ return self.sampling_params.skip_reading_prefix_cache
324
+ elif (
325
+ self.pooling_params is not None
326
+ and self.pooling_params.skip_reading_prefix_cache is not None
327
+ ):
328
+ return self.pooling_params.skip_reading_prefix_cache
329
+ return False
330
+
331
+ def is_finished(self) -> bool:
332
+ return RequestStatus.is_finished(self.status)
333
+
334
+ def get_finished_reason(self) -> FinishReason | None:
335
+ return RequestStatus.get_finished_reason(self.status)
336
+
337
+ def get_num_encoder_embeds(self, input_id: int) -> int:
338
+ assert input_id < len(self.mm_features)
339
+ return self.mm_features[input_id].mm_position.get_num_embeds()
340
+
341
+ def record_event(
342
+ self,
343
+ event_type: EngineCoreEventType,
344
+ timestamp: float | None = None,
345
+ ) -> None:
346
+ self.events.append(EngineCoreEvent.new_event(event_type, timestamp))
347
+
348
+ def take_events(self) -> list[EngineCoreEvent] | None:
349
+ if not self.events:
350
+ return None
351
+ events, self.events = self.events, []
352
+ return events
353
+
354
+ def take_prefill_stats(self) -> PrefillStats | None:
355
+ if self.prefill_stats is None:
356
+ return None
357
+ prefill_stats = self.prefill_stats
358
+ self.prefill_stats = None
359
+ return prefill_stats
360
+
361
+ def __lt__(self, other: "Request") -> bool:
362
+ """
363
+ Compare two requests based on priority, arrival time, and request ID.
364
+ Used in priority scheduling.
365
+ """
366
+ if self.priority != other.priority:
367
+ return self.priority < other.priority
368
+ if self.arrival_time != other.arrival_time:
369
+ return self.arrival_time < other.arrival_time
370
+ if self.request_id != other.request_id:
371
+ return self.request_id < other.request_id
372
+ return id(self) < id(other)
373
+
374
+
375
+ class RequestStatus(enum.IntEnum):
376
+ """Status of a request."""
377
+
378
+ WAITING = enum.auto()
379
+ WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR = enum.auto()
380
+ WAITING_FOR_REMOTE_KVS = enum.auto()
381
+ WAITING_FOR_STREAMING_REQ = enum.auto()
382
+ RUNNING = enum.auto()
383
+ PREEMPTED = enum.auto()
384
+ # Note: anything after PREEMPTED will be considered
385
+ # as a finished status.
386
+ FINISHED_STOPPED = enum.auto()
387
+ FINISHED_LENGTH_CAPPED = enum.auto()
388
+ FINISHED_ABORTED = enum.auto()
389
+ FINISHED_IGNORED = enum.auto()
390
+ FINISHED_ERROR = enum.auto()
391
+ FINISHED_REPETITION = enum.auto()
392
+
393
+ def __str__(self) -> str:
394
+ return self.name
395
+
396
+ @staticmethod
397
+ def is_finished(status: "RequestStatus") -> bool:
398
+ return status > RequestStatus.PREEMPTED
399
+
400
+ @staticmethod
401
+ def get_finished_reason(status: "RequestStatus") -> FinishReason | None:
402
+ return _FINISHED_REASON_MAP.get(status)
403
+
404
+
405
+ # Mapping of finished statuses to their finish reasons.
406
+ # NOTE: The ignored requests are the requests whose prompt lengths
407
+ # are longer than the model's length cap. Therefore, the stop
408
+ # reason should also be "length" as in OpenAI API.
409
+ _FINISHED_REASON_MAP = {
410
+ RequestStatus.FINISHED_STOPPED: FinishReason.STOP,
411
+ RequestStatus.FINISHED_LENGTH_CAPPED: FinishReason.LENGTH,
412
+ RequestStatus.FINISHED_ABORTED: FinishReason.ABORT,
413
+ RequestStatus.FINISHED_IGNORED: FinishReason.LENGTH,
414
+ RequestStatus.FINISHED_ERROR: FinishReason.ERROR,
415
+ RequestStatus.WAITING_FOR_STREAMING_REQ: FinishReason.STOP,
416
+ RequestStatus.FINISHED_REPETITION: FinishReason.REPETITION,
417
+ }
serving/fork/v1/worker/gpu_model_runner.py ADDED
The diff for this file is too large to render. See raw diff
 
serving/orchestrator/README.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # hybridagent-orchestrator (T5)
2
+
3
+ The EXTERNAL ORCHESTRATOR for the forked vLLM deployment: it wraps an
4
+ IN-PROCESS `vllm.LLMEngine` and runs the HybridAgent agentic loop that
5
+ the T4 state machine (`processor/`) cannot finish on its own.
6
+
7
+ ## What it does
8
+
9
+ - **Engine construction** with the fork's required semantics:
10
+ `VLLM_USE_V2_MODEL_RUNNER=0` (the T2 fork modifies the V1 runner; the
11
+ V2 default runner silently disables the latent path) and
12
+ `async_scheduling=False` (the async scheduler keeps `num_new_tokens > 0`
13
+ every step, which blocks the fork's zero-token latent gate).
14
+ - **Arms the T4 state machine** via `processor.register_engine(...)` with
15
+ `get_request` / `get_req_ids` closures bound to the in-process
16
+ `scheduler.requests` and `model_runner.input_batch.req_ids`, the
17
+ tokenizer, a `tool_executor` and the in-process `inject_token`.
18
+ - **`add_request(...)`** with the `<latent>`-capable sampling params
19
+ (greedy, no penalties/structured output, a `max_tokens` budget).
20
+ - **`run(...)`** step loop: `engine.step()`; after each step, for every
21
+ request paused in `IDLE`, read `pending_tool` (tag, content), call
22
+ `tool_executor(tag, content)`, tokenize `<result>{...}</result>`
23
+ (`add_special_tokens=False`) and `queue_observation(rid, tokens)`;
24
+ collect the final text on DONE.
25
+ - **Entry-step alignment**: pre-arms `request.pending_latent` as soon as
26
+ `<latent>` lands in the output, so the step that PROCESSES the anchor
27
+ is the fork's entry row (its sample is dropped).
28
+ - **Exit-anchor injection** (`inject_token`): appends `</latent>` as a
29
+ real sequence token by direct request mutation —
30
+ `request.append_output_token_ids(tok)` + the worker feed buffer
31
+ (`input_batch.token_ids_cpu[req_idx, num_tokens_no_spec[req_idx]]` +
32
+ `is_token_ids`) + re-arming `pending_latent` so the fork drops the
33
+ exit row's own sample (no double-append); the run loop clears the flag
34
+ before the next schedule.
35
+
36
+ ## Tools (`orchestrator.tools`)
37
+
38
+ - `python`: bounded, isolated exec (`python -I`, wall-clock timeout,
39
+ capped output) capturing stdout/stderr; returns a string
40
+ (`ERROR: ...` on failure). A resource boundary, not a security sandbox.
41
+ - `search`: Tavily API when `TAVILY_API_KEY` is set; a clear
42
+ not-configured stub otherwise (no network without the key).
43
+
44
+ Both follow the reference contract: `(tag, content) -> result text`.
45
+
46
+ ## T7 serve requirements
47
+
48
+ - env: `VLLM_USE_V2_MODEL_RUNNER=0`, `async_scheduling=False`
49
+ - entry points to install: `vllm.general_plugins` (the T3 backend,
50
+ `pip install -e /opt/hybridagent-vllm/backend`) and
51
+ `vllm.logits_processors` (the T4 state machine,
52
+ `pip install -e /opt/hybridagent-vllm/processor`)
53
+ - this package (no entry points of its own) is imported by the serving
54
+ process to drive the engine.
55
+
56
+ Validated by `poc/test_orchestrator_synthetic.py` (tiny HybridAgent model
57
+ through the real fork + backend + state machine: latent cycle, one
58
+ `</python>` tool call, `<result>` re-feed, DONE with coherent text).
serving/orchestrator/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HybridAgent external orchestrator (T5).
2
+
3
+ Drives a forked in-process vLLM ``LLMEngine`` step-by-step and runs the
4
+ HybridAgent tool barrier on top of the T4 state machine
5
+ (``HybridAgentLogitsProcessor``):
6
+
7
+ orch = HybridAgentOrchestrator(
8
+ model, # HybridAgent checkpoint dir
9
+ tool_executor=..., # (tag, content) -> result text
10
+ max_turns=30,
11
+ **llm_kwargs, # dtype, enforce_eager, logits_processors, ...
12
+ )
13
+ rid = orch.add_request(prompt) # <latent>-capable sampling params
14
+ results = orch.run() # engine.step() cadence + tool barrier
15
+
16
+ Engine semantics are fixed for the fork: ``VLLM_USE_V2_MODEL_RUNNER=0``
17
+ and ``async_scheduling=False`` (see ``orchestrator.orchestrator``).
18
+
19
+ Install into the serving venv (no entry points; imported as a package):
20
+
21
+ pip install -e /opt/hybridagent-vllm/orchestrator
22
+ """
23
+ from orchestrator.orchestrator import (
24
+ BarrierEvent,
25
+ HybridAgentOrchestrator,
26
+ )
27
+ from orchestrator.tools import (
28
+ ToolExecutor,
29
+ make_default_tool_executor,
30
+ run_python,
31
+ run_search,
32
+ )
33
+
34
+ __all__ = [
35
+ "HybridAgentOrchestrator",
36
+ "BarrierEvent",
37
+ "ToolExecutor",
38
+ "make_default_tool_executor",
39
+ "run_python",
40
+ "run_search",
41
+ ]
serving/orchestrator/orchestrator.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """External orchestrator for the forked in-process vLLM engine (T5).
2
+
3
+ Drives the HybridAgent agentic loop around a real in-process vLLM
4
+ ``LLMEngine``:
5
+
6
+ * builds the engine with the fork's required semantics --
7
+ ``VLLM_USE_V2_MODEL_RUNNER=0`` (the T2 fork modifies the V1 runner;
8
+ vLLM 0.30 defaults to V2, where the latent path is silently inactive)
9
+ and ``async_scheduling=False`` (the async scheduler reserves output
10
+ placeholders every step, which keeps ``num_new_tokens > 0`` and
11
+ prevents the fork's zero-token latent gate; L8) -- with an in-process
12
+ engine core so the T4 state machine's registered handles reach
13
+ ``scheduler.requests`` and ``model_runner.input_batch.req_ids``;
14
+ * arms the T4 ``HybridAgentLogitsProcessor`` via ``register_engine``
15
+ with the engine-side request lookup, the worker batch order, the
16
+ tokenizer, a ``tool_executor`` and the in-process ``inject_token``
17
+ sequence append;
18
+ * runs the step loop: ``engine.step()`` -> service every request paused
19
+ in IDLE at the tool barrier (run the tool, tokenize
20
+ ``<result>...</result>`` with ``add_special_tokens=False``,
21
+ ``queue_observation``) -> repeat until every request is done.
22
+
23
+ The two loop behaviors the state machine cannot do from inside the logits
24
+ processor are done here:
25
+
26
+ 1. **Entry-step alignment.** The state machine arms
27
+ ``request.pending_latent`` one step AFTER it sees ``<latent>``
28
+ sampled; the fork's entry row is the step that PROCESSES ``<latent>``
29
+ (its sample is dropped so the position becomes a latent KV position).
30
+ The orchestrator pre-arms ``pending_latent`` as soon as the entry
31
+ anchor lands in the output -- the same alignment the T2b e2e harness
32
+ used.
33
+
34
+ 2. **Exit-anchor injection.** ``inject_token`` appends ``</latent>`` as
35
+ a real sequence token at a latent exit (direct request mutation, see
36
+ the method) and re-arms ``pending_latent`` so the fork's exit-row
37
+ check drops that row's own sample (no double-append of the anchor).
38
+ The run loop clears ``pending_latent`` before the next schedule, so
39
+ the following row is a plain token row that feeds ``</latent>``.
40
+
41
+ The orchestrator also finishes a request at the state machine's DONE
42
+ (e.g. max_turns, fabricated ``<result>``) by aborting it in the engine,
43
+ mirroring the reference engine's stop at DONE.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import os
48
+ import uuid
49
+ from dataclasses import dataclass
50
+
51
+ # Fork semantics (L6/L8): force the V1 (forked) runner and the in-process
52
+ # engine core. setdefault: an explicit environment wins.
53
+ os.environ.setdefault("VLLM_USE_V2_MODEL_RUNNER", "0")
54
+ os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
55
+
56
+ from .tools import ToolExecutor, make_default_tool_executor # noqa: E402
57
+
58
+ __all__ = [
59
+ "HybridAgentOrchestrator",
60
+ "ToolExecutor",
61
+ "make_default_tool_executor",
62
+ "BarrierEvent",
63
+ ]
64
+
65
+
66
+ @dataclass
67
+ class BarrierEvent:
68
+ """One tool-barrier service: a request paused in IDLE ran a tool."""
69
+
70
+ request_id: str
71
+ tag: str
72
+ content: str
73
+ result: str
74
+ step: int
75
+
76
+
77
+ class HybridAgentOrchestrator:
78
+ """Wraps an in-process vLLM ``LLMEngine`` and runs the tool barrier.
79
+
80
+ Args:
81
+ model: checkpoint path (a HybridAgent dir: Qwen3 weights +
82
+ ``adapter.pt``; the T3 backend must be registered in this
83
+ process, e.g. ``backend.register()`` or the installed
84
+ ``vllm.general_plugins`` entry point).
85
+ tool_executor: ``(tag, content) -> result text``; defaults to
86
+ :func:`make_default_tool_executor` (bounded python +
87
+ Tavily/stub search).
88
+ max_turns: the reference turn cap (default 30).
89
+ llm_kwargs: passed through to ``vllm.LLM`` (``dtype``,
90
+ ``enforce_eager``, ``max_model_len``,
91
+ ``gpu_memory_utilization``, ``logits_processors``, ...).
92
+ ``async_scheduling`` is forced to ``False``.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ model: str,
98
+ *,
99
+ tool_executor: ToolExecutor | None = None,
100
+ max_turns: int = 30,
101
+ **llm_kwargs,
102
+ ) -> None:
103
+ from vllm import LLM
104
+
105
+ llm_kwargs.pop("async_scheduling", None)
106
+ llm_kwargs.setdefault("disable_log_stats", True)
107
+ self.llm = LLM(model=model, async_scheduling=False, **llm_kwargs)
108
+ self.engine = self.llm.llm_engine
109
+ # In-process engine core (VLLM_ENABLE_V1_MULTIPROCESSING=0):
110
+ # engine_core is the InprocClient; its .engine_core is the
111
+ # EngineCore holding the scheduler and the model executor.
112
+ self.core = getattr(
113
+ self.engine.engine_core, "engine_core", self.engine.engine_core
114
+ )
115
+ self.scheduler = self.core.scheduler
116
+ self.runner = self.core.model_executor.driver_worker.model_runner
117
+ self.tokenizer = self.engine.get_tokenizer()
118
+ self.max_turns = int(max_turns)
119
+ self.tool_executor = (
120
+ tool_executor if tool_executor is not None
121
+ else make_default_tool_executor()
122
+ )
123
+
124
+ # Fork handle: the exit-row sample keep/drop reads
125
+ # request.pending_latent through this lookup (None in
126
+ # multi-process engines, where every latent-row sample is
127
+ # dropped and the exit anchor is forced in the logits).
128
+ self.runner._fork_get_request = self.scheduler.requests.get
129
+
130
+ from processor import processor as proc_mod
131
+
132
+ self._proc = proc_mod
133
+ self.entry_id = proc_mod.special_token_id(
134
+ self.tokenizer, proc_mod.ENTRY_ANCHOR
135
+ )
136
+ self.exit_id = proc_mod.special_token_id(
137
+ self.tokenizer, proc_mod.EXIT_ANCHOR
138
+ )
139
+ proc_mod.register_engine(
140
+ get_request=self.scheduler.requests.get,
141
+ get_req_ids=lambda: list(self.runner.input_batch.req_ids),
142
+ tokenizer=self.tokenizer,
143
+ tool_executor=self.tool_executor,
144
+ inject_token=self.inject_token,
145
+ max_turns=self.max_turns,
146
+ )
147
+
148
+ self.barrier_log: list[BarrierEvent] = []
149
+ self.injected: list[tuple[str, int]] = []
150
+ self._pending_clear: set[str] = set()
151
+ self._final: dict[str, object] = {}
152
+ # External request id -> engine-internal id. vLLM 0.30 randomizes
153
+ # the internal id (assign_request_id); scheduler.requests, the
154
+ # worker batch and the state machine are keyed by the internal id,
155
+ # while RequestOutput.request_id carries the external one.
156
+ self._rid_map: dict[str, str] = {}
157
+
158
+ # ------------------------------------------------------------------
159
+ # requests
160
+ # ------------------------------------------------------------------
161
+ def add_request(
162
+ self,
163
+ prompt,
164
+ request_id: str | None = None,
165
+ sampling_params=None,
166
+ **params_kwargs,
167
+ ) -> str:
168
+ """Submit a prompt to the engine; returns the request id.
169
+
170
+ ``sampling_params=None`` builds the ``<latent>``-capable
171
+ defaults: greedy decoding (``temperature=0``), no
172
+ penalties/bad-words/structured output (the state machine owns
173
+ token control), and a ``max_tokens`` budget (``params_kwargs``,
174
+ default 2048).
175
+ """
176
+ from vllm import SamplingParams
177
+
178
+ if sampling_params is None:
179
+ sampling_params = SamplingParams(
180
+ temperature=0.0,
181
+ max_tokens=params_kwargs.pop("max_tokens", 2048),
182
+ **params_kwargs,
183
+ )
184
+ ext_rid = request_id or f"ha-{uuid.uuid4().hex[:12]}"
185
+ internal_rid = self.engine.add_request(ext_rid, prompt, sampling_params)
186
+ self._rid_map[ext_rid] = internal_rid
187
+ return ext_rid
188
+
189
+ def engine_request_id(self, rid: str) -> str:
190
+ """The engine-internal request id for an external request id
191
+ (the key used by ``scheduler.requests``, the worker batch and the
192
+ state machine)."""
193
+ return self._rid_map[rid]
194
+
195
+ # ------------------------------------------------------------------
196
+ # in-process sequence append (the T4-registered inject_token)
197
+ # ------------------------------------------------------------------
198
+ def inject_token(self, rid: str, tok: int) -> None:
199
+ """Append ``tok`` to a running sequence (direct request mutation).
200
+
201
+ Exact path (single worker, last rank, synchronous scheduling):
202
+
203
+ 1. engine-side sequence: ``request.append_output_token_ids(tok)``
204
+ -- ``_output_token_ids``/``_all_token_ids`` grow, so the
205
+ scheduler counts the token as pending on the next pass and
206
+ the block hashes extend;
207
+ 2. worker-side feed buffer: ``input_batch.token_ids_cpu[req_idx,
208
+ pos] = tok`` where ``pos = input_batch.num_tokens_no_spec[
209
+ req_idx]`` -- the next slot the sampler would have written
210
+ this step's sample to -- then advance ``num_tokens_no_spec``
211
+ and mark ``is_token_ids[req_idx, pos] = True`` (a real token
212
+ slot, not a prompt-embed slot);
213
+ 3. worker-side cache: ``runner.requests[rid].output_token_ids``
214
+ (penalty bookkeeping; no penalties here, but kept in sync);
215
+ 4. fork sample gate: ``request.pending_latent = True`` so the
216
+ fork's exit-row check (``keep = not req.pending_latent``)
217
+ drops this step's own sample -- no double-append of the exit
218
+ anchor;
219
+ 5. the run loop clears ``pending_latent`` before the next
220
+ schedule, otherwise the following row would be read as a
221
+ latent ENTRY row (``pending_latent and num_new_tokens > 0``).
222
+ """
223
+ req = self.scheduler.requests[rid]
224
+ req.append_output_token_ids(tok)
225
+ ib = self.runner.input_batch
226
+ idx = ib.req_id_to_index[rid]
227
+ pos = int(ib.num_tokens_no_spec[idx])
228
+ ib.token_ids_cpu[idx, pos] = tok
229
+ ib.is_token_ids[idx, pos] = True
230
+ ib.num_tokens_no_spec[idx] = pos + 1
231
+ cached = self.runner.requests.get(rid)
232
+ if cached is not None:
233
+ cached.output_token_ids.append(tok)
234
+ req.pending_latent = True
235
+ self._pending_clear.add(rid)
236
+ self.injected.append((rid, int(tok)))
237
+
238
+ # ------------------------------------------------------------------
239
+ # the run loop
240
+ # ------------------------------------------------------------------
241
+ def run(self, max_steps: int = 10_000) -> dict:
242
+ """Drive all submitted requests to completion.
243
+
244
+ Returns ``{request_id: RequestOutput}`` for the finished
245
+ requests.
246
+ """
247
+ step_no = 0
248
+ while self.engine.has_unfinished_requests():
249
+ if step_no >= max_steps:
250
+ raise RuntimeError(
251
+ f"orchestrator step cap ({max_steps}) reached with "
252
+ f"{self.engine.get_num_unfinished_requests()} "
253
+ "request(s) unfinished"
254
+ )
255
+ self._pre_step()
256
+ step_no += 1
257
+ outputs = self.engine.step()
258
+ self._post_step(outputs, step_no)
259
+ return dict(self._final)
260
+
261
+ def final_text(self, rid: str) -> str:
262
+ """The engine-side final text of a finished request."""
263
+ out = self._final.get(rid)
264
+ if out is None or not getattr(out, "outputs", None):
265
+ return ""
266
+ return out.outputs[0].text
267
+
268
+ def shutdown(self) -> None:
269
+ """Shut the in-process engine core down (frees the GPU)."""
270
+ self.engine.engine_core.shutdown()
271
+
272
+ # -- per-step hooks -------------------------------------------------
273
+ def _pre_step(self) -> None:
274
+ # Clear the exit-row re-arm (set by inject_token) so the next
275
+ # schedule reads a plain token row, not a latent entry row.
276
+ for rid in list(self._pending_clear):
277
+ req = self.scheduler.requests.get(rid)
278
+ if req is not None:
279
+ req.pending_latent = False
280
+ self._pending_clear.discard(rid)
281
+
282
+ def _post_step(self, outputs, step_no: int) -> None:
283
+ for out in outputs:
284
+ rid = out.request_id # external id
285
+ if out.finished:
286
+ self._final[rid] = out
287
+ internal = self._rid_map.get(rid, rid)
288
+ self._service_barrier(rid, internal, step_no)
289
+ self._arm_entry(internal)
290
+ self._finish_if_done(rid, internal)
291
+
292
+ def _service_barrier(self, rid: str, internal: str, step_no: int) -> None:
293
+ """Tool barrier: run the pending tool and re-inject the result."""
294
+ st = self._proc.get_state(internal)
295
+ if st is None or st.mode != "IDLE" or st.pending_tool is None:
296
+ return
297
+ tag, content = st.pending_tool
298
+ result = self.tool_executor(tag, content)
299
+ self.barrier_log.append(
300
+ BarrierEvent(
301
+ request_id=rid,
302
+ tag=tag,
303
+ content=content,
304
+ result=result,
305
+ step=step_no,
306
+ )
307
+ )
308
+ self._proc.record_tool_call(internal, tag)
309
+ obs_ids = self.tokenizer(
310
+ f"<result>{result}</result>", add_special_tokens=False
311
+ )["input_ids"]
312
+ self._proc.queue_observation(internal, list(obs_ids))
313
+
314
+ def _arm_entry(self, rid: str) -> None:
315
+ """Pre-arm pending_latent once <latent> lands in the output.
316
+
317
+ The state machine's own write happens one step later (it detects
318
+ the entry from the sampled token on the next apply); the fork's
319
+ entry row is the step that PROCESSES the anchor, so the flag
320
+ must be set before that row is scheduled.
321
+ """
322
+ st = self._proc.get_state(rid)
323
+ req = self.scheduler.requests.get(rid)
324
+ if st is None or req is None:
325
+ return
326
+ if (
327
+ st.mode == "DECODE"
328
+ and req._output_token_ids
329
+ and req._output_token_ids[-1] == self.entry_id
330
+ and not req.pending_latent
331
+ ):
332
+ req.pending_latent = True
333
+
334
+ def _finish_if_done(self, rid: str, internal: str) -> None:
335
+ """Abort the engine request once the state machine is DONE.
336
+
337
+ The reference engine stops at DONE (max_turns, fabricated
338
+ <result>, budget); vLLM would otherwise keep sampling until its
339
+ own eos/max_tokens.
340
+ """
341
+ st = self._proc.get_state(internal)
342
+ if st is None or st.mode != "DONE":
343
+ return
344
+ req = self.scheduler.requests.get(internal)
345
+ if req is None:
346
+ return
347
+ from vllm.v1.request import RequestStatus
348
+
349
+ if req.status == RequestStatus.RUNNING:
350
+ self.engine.abort_request([rid])
serving/orchestrator/pyproject.toml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hybridagent-orchestrator"
7
+ version = "0.1.0"
8
+ description = "External orchestrator: drives the forked in-process vLLM engine and runs the HybridAgent tool barrier (T5)"
9
+ requires-python = ">=3.10"
10
+ # No entry points: this package is imported by the serving process. The
11
+ # engine-side plugins are the T3 backend (vllm.general_plugins) and the
12
+ # T4 state machine (vllm.logits_processors).
13
+
14
+ [tool.setuptools]
15
+ packages = ["orchestrator"]
serving/orchestrator/tools.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool executors for the HybridAgent tool barrier (T5).
2
+
3
+ Reference parity (togethercomputer/HybridAgent ``ToolExecutor``): one tool
4
+ call is ``(tag, content) -> result text``. The orchestrator wraps the
5
+ returned text in ``<result>...</result>``, tokenizes it and re-injects it
6
+ as FEED tokens for the paused request.
7
+
8
+ Two tools (``tag`` in {"python", "search"}, the state machine's
9
+ ``TOOL_TAGS``):
10
+
11
+ * ``python`` -- runs ``content`` as Python in a bounded, isolated
12
+ interpreter (``python -I``: no PYTHONPATH, no environment, no user
13
+ site-packages), with a wall-clock timeout and a capped output. Returns
14
+ the captured stdout, or an ``ERROR: ...`` string on failure/timeout.
15
+ This is a resource boundary (time, output, environment isolation), not
16
+ a security sandbox: the code still runs with the process's filesystem
17
+ permissions.
18
+ * ``search`` -- web search via the Tavily API when ``TAVILY_API_KEY`` is
19
+ set in the environment; otherwise a clear not-configured stub string.
20
+ No network activity happens without the key.
21
+
22
+ Both return plain strings (the reference contract).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ import urllib.error
31
+ import urllib.request
32
+
33
+ __all__ = [
34
+ "ToolExecutor",
35
+ "make_default_tool_executor",
36
+ "run_python",
37
+ "run_search",
38
+ ]
39
+
40
+ PYTHON_TIMEOUT_S = 10.0
41
+ PYTHON_MAX_OUTPUT = 8192
42
+ SEARCH_TIMEOUT_S = 20.0
43
+ SEARCH_MAX_OUTPUT = 8192
44
+ TAVILY_ENDPOINT = "https://api.tavily.com/search"
45
+
46
+
47
+ def _truncate(text: str, limit: int) -> str:
48
+ return text if len(text) <= limit else text[:limit] + "...<truncated>"
49
+
50
+
51
+ def run_python(
52
+ code: str,
53
+ *,
54
+ timeout: float = PYTHON_TIMEOUT_S,
55
+ max_output: int = PYTHON_MAX_OUTPUT,
56
+ ) -> str:
57
+ """Run ``code`` in a bounded, isolated interpreter; return a string."""
58
+ if not isinstance(code, str) or not code.strip():
59
+ return "ERROR: empty python code"
60
+ env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin")}
61
+ try:
62
+ proc = subprocess.run(
63
+ [sys.executable, "-I", "-c", code],
64
+ capture_output=True,
65
+ text=True,
66
+ timeout=timeout,
67
+ env=env,
68
+ )
69
+ except subprocess.TimeoutExpired:
70
+ return f"ERROR: python tool timed out after {timeout:.0f}s"
71
+ out = (proc.stdout or "").strip()
72
+ if proc.returncode != 0:
73
+ err_lines = (proc.stderr or "").strip().splitlines()
74
+ last = err_lines[-1] if err_lines else f"exit code {proc.returncode}"
75
+ return f"ERROR: {last}"
76
+ return _truncate(out, max_output)
77
+
78
+
79
+ def run_search(
80
+ query: str,
81
+ *,
82
+ timeout: float = SEARCH_TIMEOUT_S,
83
+ max_output: int = SEARCH_MAX_OUTPUT,
84
+ ) -> str:
85
+ """Web search via Tavily when configured; a stub string otherwise."""
86
+ key = os.environ.get("TAVILY_API_KEY", "").strip()
87
+ if not key:
88
+ return (
89
+ "web_search not configured: TAVILY_API_KEY is not set; no results."
90
+ )
91
+ if not isinstance(query, str) or not query.strip():
92
+ return "ERROR: empty search query"
93
+ body = json.dumps(
94
+ {"api_key": key, "query": query, "max_results": 3}
95
+ ).encode("utf-8")
96
+ req = urllib.request.Request(
97
+ TAVILY_ENDPOINT,
98
+ data=body,
99
+ headers={"Content-Type": "application/json"},
100
+ )
101
+ try:
102
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
103
+ data = json.loads(resp.read().decode("utf-8"))
104
+ except (urllib.error.URLError, OSError, ValueError) as exc:
105
+ return f"ERROR: search failed: {exc}"
106
+ parts: list[str] = []
107
+ if data.get("answer"):
108
+ parts.append(str(data["answer"]))
109
+ for result in (data.get("results") or [])[:3]:
110
+ title = result.get("title") or ""
111
+ content = result.get("content") or ""
112
+ parts.append(f"{title}: {content}" if title else content)
113
+ out = "\n".join(p for p in parts if p).strip()
114
+ return _truncate(out, max_output) if out else "no results"
115
+
116
+
117
+ class ToolExecutor:
118
+ """``(tag, content) -> result text`` for the orchestrator's tool barrier."""
119
+
120
+ def __init__(self, python=None, search=None) -> None:
121
+ self.python = python if python is not None else run_python
122
+ self.search = search if search is not None else run_search
123
+
124
+ def __call__(self, tag: str, content: str) -> str:
125
+ if tag == "python":
126
+ return self.python(content)
127
+ if tag == "search":
128
+ return self.search(content)
129
+ return f"ERROR: unknown tool {tag!r}"
130
+
131
+
132
+ def make_default_tool_executor() -> ToolExecutor:
133
+ """The default barrier executor (bounded python + Tavily/stub search)."""
134
+ return ToolExecutor()
serving/processor/__init__.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HybridAgent vLLM stateful logits processor (T4).
2
+
3
+ The DECODE/LATENT/FEED/IDLE/DONE generation state machine from
4
+ togethercomputer/HybridAgent running inside vLLM as a stateful logits
5
+ processor (``vllm.logits_processors`` entry-point group). Drives the
6
+ fork's zero-token latent steps via ``request.pending_latent`` and
7
+ forces tokens (latent exit anchor, FEED observations) via logits
8
+ masking.
9
+
10
+ Install into the serving venv to activate:
11
+
12
+ pip install -e /opt/hybridagent-vllm/processor
13
+
14
+ T5 (the external orchestrator) arms it once, in the engine process:
15
+
16
+ import processor
17
+ processor.register_engine(
18
+ get_request=..., # engine-side Request by req_id
19
+ get_req_ids=..., # current worker batch order
20
+ tokenizer=..., # <latent>/</latent> resolved at runtime
21
+ tool_executor=..., # (tag, content) -> result text
22
+ inject_token=..., # in-process sequence append (exit anchor)
23
+ max_turns=30,
24
+ )
25
+ # at each tool barrier (request paused in IDLE):
26
+ tag, content = processor.get_state(req_id).pending_tool
27
+ result = tool_executor(tag, content)
28
+ processor.queue_observation(
29
+ req_id,
30
+ tokenizer(f"<result>{result}</result>",
31
+ add_special_tokens=False)["input_ids"],
32
+ )
33
+ """
34
+ from processor.processor import (
35
+ HybridAgentLogitsProcessor,
36
+ get_state,
37
+ queue_observation,
38
+ register_engine,
39
+ record_tool_call,
40
+ )
41
+ from processor.state_machine import (
42
+ DECODE,
43
+ DONE,
44
+ FEED,
45
+ IDLE,
46
+ LATENT,
47
+ RequestState,
48
+ )
49
+
50
+ __all__ = [
51
+ "HybridAgentLogitsProcessor",
52
+ "register_engine",
53
+ "queue_observation",
54
+ "record_tool_call",
55
+ "get_state",
56
+ "RequestState",
57
+ "DECODE",
58
+ "LATENT",
59
+ "FEED",
60
+ "IDLE",
61
+ "DONE",
62
+ ]
serving/processor/processor.py ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """vLLM stateful logits processor: the HybridAgent state machine (T4).
2
+
3
+ Wired through the ``vllm.logits_processors`` entry-point group (see
4
+ pyproject.toml):
5
+
6
+ [project.entry-points."vllm.logits_processors"]
7
+ hybridagent_state_machine = "processor.processor:HybridAgentLogitsProcessor"
8
+
9
+ The processor mirrors the ``AdapterLogitsProcessor`` pattern
10
+ (``vllm/v1/sample/logits_processor/``): a batch-index -> state map
11
+ maintained from ``BatchUpdate`` (the ``output_tok_ids`` entries are
12
+ live references to each request's running output list), advanced in
13
+ ``apply()`` from the live token stream and the current logits' argmax.
14
+
15
+ Fork integration (branch ``hybridagent-latent-fork``):
16
+
17
+ * ``request.pending_latent`` -- written on the engine-side ``Request``
18
+ (resolved through the T5-registered request store) so the fork's
19
+ scheduler gate (``scheduler.py``: ``pending_latent and
20
+ num_new_tokens == 0 -> latent_positions = 1``) schedules the
21
+ zero-token latent step. vLLM 0.30's ``BatchUpdate`` carries only
22
+ ``SamplingParams`` + output-token references and its
23
+ ``ForwardContext`` exposes no model-runner handle, so the engine-side
24
+ request is reached through the in-process store T5 registers (the T5
25
+ deployment is the in-process ``LLMEngine``).
26
+ * Forced tokens -- the logits row is masked to a single id (latent
27
+ exit anchor fallback, FEED observation tokens).
28
+ * Exit-anchor injection -- in-process engines: T5-registered
29
+ ``inject_token(req_id, token_id)`` appends ``</latent>`` to the
30
+ sequence at a latent exit (reference parity: ``<latent>``, n latent
31
+ forwards, ``</latent>``).
32
+
33
+ T4b -- reference ``blocked_token_sets`` (engine.py:140-149): every
34
+ DECODE (text) logits row is masked to ``-inf`` at the blocked anchor
35
+ ids, from the first step: in ``<think>`` block ``</latent>`` (and
36
+ ``<think>``), out of ``<think>`` block ``</latent>``, ``<latent>``
37
+ (and ``</think>``). ``in_think`` is initialized from the prompt
38
+ (engine.py:221) and tracked per request from the generated
39
+ ``<think>``/``</think>`` tokens; LATENT exit readouts stay raw. The
40
+ masks are applied before the single-token forcing, so FEED/exit
41
+ forcing is unchanged. Non-agent requests still get no
42
+ ``pending_latent`` writes.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ from dataclasses import dataclass, field
47
+ from typing import TYPE_CHECKING, Any, Callable
48
+
49
+ import torch
50
+
51
+ from vllm import SamplingParams
52
+ from vllm.v1.sample.logits_processor import (
53
+ BatchUpdate,
54
+ LogitsProcessor,
55
+ process_dict_updates,
56
+ )
57
+
58
+ from processor.state_machine import (
59
+ DEFAULT_MAX_TURNS,
60
+ ENTRY_ANCHOR,
61
+ EXIT_ANCHOR,
62
+ THINK_CLOSE,
63
+ THINK_OPEN,
64
+ TOOL_TAG_TAIL_TOKENS,
65
+ RequestState,
66
+ StepAction,
67
+ advance,
68
+ queue_observation as _sm_queue_observation,
69
+ record_tool_call as _sm_record_tool_call,
70
+ )
71
+
72
+ if TYPE_CHECKING:
73
+ from vllm.config import VllmConfig
74
+
75
+ __all__ = [
76
+ "HybridAgentLogitsProcessor",
77
+ "register_engine",
78
+ "queue_observation",
79
+ "record_tool_call",
80
+ "get_state",
81
+ "special_token_id",
82
+ ]
83
+
84
+ # Live processor instances in this process (one worker, one instance).
85
+ _PROCESSORS: list["HybridAgentLogitsProcessor"] = []
86
+
87
+
88
+ def special_token_id(tokenizer: Any, token: str) -> int:
89
+ """Id of a special token; fails loudly if the tokenizer does not know
90
+ it (reference ``constants.special_token_id``)."""
91
+ tok_id = tokenizer.convert_tokens_to_ids(token)
92
+ unk_id = getattr(tokenizer, "unk_token_id", None)
93
+ if tok_id is None or (unk_id is not None and tok_id == unk_id):
94
+ raise ValueError(
95
+ f"Tokenizer does not know the special token {token!r}; "
96
+ "load a HybridAgent checkpoint or add the anchor tokens first."
97
+ )
98
+ return int(tok_id)
99
+
100
+
101
+ def _optional_token_id(tokenizer: Any, token: str) -> int | None:
102
+ """T4b: token id or ``None`` when the tokenizer does not know it.
103
+
104
+ The think-tag guards (reference ``think_guards``) degrade to
105
+ anchor-only masks in that case (reference ``think_guards=False``
106
+ path); they never block arming the engine.
107
+ """
108
+ try:
109
+ tok_id = tokenizer.convert_tokens_to_ids(token)
110
+ except Exception:
111
+ return None
112
+ unk_id = getattr(tokenizer, "unk_token_id", None)
113
+ if tok_id is None or (unk_id is not None and tok_id == unk_id):
114
+ return None
115
+ return int(tok_id)
116
+
117
+
118
+ @dataclass
119
+ class EngineHandles:
120
+ """Process-level handles T5 registers once (in-process engine)."""
121
+
122
+ get_request: Callable[[str], Any] | None = None
123
+ get_req_ids: Callable[[], list[str] | None] | None = None
124
+ tokenizer: Any | None = None
125
+ tool_executor: Callable[[str, str], str] | None = None
126
+ inject_token: Callable[[str, int], None] | None = None
127
+ max_turns: int = DEFAULT_MAX_TURNS
128
+ entry_id: int | None = None
129
+ exit_id: int | None = None
130
+ think_open_id: int | None = None
131
+ think_close_id: int | None = None
132
+ eos_token_ids: frozenset[int] = field(default_factory=frozenset)
133
+
134
+
135
+ _engine: EngineHandles | None = None
136
+
137
+
138
+ def register_engine(
139
+ *,
140
+ get_request: Callable[[str], Any] | None = None,
141
+ get_req_ids: Callable[[], list[str] | None] | None = None,
142
+ tokenizer: Any | None = None,
143
+ tool_executor: Callable[[str, str], str] | None = None,
144
+ inject_token: Callable[[str, int], None] | None = None,
145
+ max_turns: int = DEFAULT_MAX_TURNS,
146
+ latent_open_id: int | None = None,
147
+ latent_close_id: int | None = None,
148
+ ) -> None:
149
+ """T5 interface: arm the state machine in this process.
150
+
151
+ Args:
152
+ get_request: engine-side ``Request`` by req_id (the object the
153
+ fork's scheduler reads ``pending_latent`` from).
154
+ get_req_ids: current worker batch order (logits row i is
155
+ ``req_ids[i]``).
156
+ tokenizer: the HybridAgent tokenizer; the ``<latent>`` /
157
+ ``</latent>`` ids are resolved from it at runtime (explicit
158
+ ``latent_open_id`` / ``latent_close_id`` override). The
159
+ T4b think-tag ids (``<think>`` / ``</think>``) are resolved
160
+ leniently: unknown tag -> anchor-only masks.
161
+ tool_executor: ``(tag, content) -> result text`` for one
162
+ pending tool call (tag in {"python", "search"}).
163
+ inject_token: in-process sequence append used to feed the exit
164
+ anchor at a latent exit (reference parity).
165
+ max_turns: the reference turn cap (default 30).
166
+ """
167
+ global _engine
168
+ entry_id = latent_open_id
169
+ exit_id = latent_close_id
170
+ think_open: int | None = None
171
+ think_close: int | None = None
172
+ eos: set[int] = set()
173
+ if tokenizer is not None:
174
+ if entry_id is None:
175
+ entry_id = special_token_id(tokenizer, ENTRY_ANCHOR)
176
+ if exit_id is None:
177
+ exit_id = special_token_id(tokenizer, EXIT_ANCHOR)
178
+ # T4b: think-tag ids for the blocked-set guards (lenient:
179
+ # unknown tag -> anchor-only masks).
180
+ think_open = _optional_token_id(tokenizer, THINK_OPEN)
181
+ think_close = _optional_token_id(tokenizer, THINK_CLOSE)
182
+ cand = getattr(tokenizer, "eos_token_id", None)
183
+ if cand is not None:
184
+ eos.update(cand if isinstance(cand, (list, tuple)) else [cand])
185
+ if entry_id is None or exit_id is None:
186
+ raise ValueError(
187
+ "register_engine: the <latent>/</latent> ids must be "
188
+ "resolvable from the tokenizer (or passed explicitly)."
189
+ )
190
+ _engine = EngineHandles(
191
+ get_request=get_request,
192
+ get_req_ids=get_req_ids,
193
+ tokenizer=tokenizer,
194
+ tool_executor=tool_executor,
195
+ inject_token=inject_token,
196
+ max_turns=max_turns,
197
+ entry_id=int(entry_id),
198
+ exit_id=int(exit_id),
199
+ think_open_id=think_open,
200
+ think_close_id=think_close,
201
+ eos_token_ids=frozenset(int(i) for i in eos),
202
+ )
203
+
204
+
205
+ def _find_state(req_id: str) -> RequestState | None:
206
+ eng = _engine
207
+ if eng is None or eng.get_req_ids is None:
208
+ return None
209
+ req_ids = eng.get_req_ids()
210
+ if req_ids is None or req_id not in req_ids:
211
+ return None
212
+ idx = req_ids.index(req_id)
213
+ for proc in _PROCESSORS:
214
+ if idx in proc.info:
215
+ return proc.info[idx][0]
216
+ return None
217
+
218
+
219
+ def queue_observation(req_id: str, token_ids: list[int]) -> None:
220
+ """T5 tool barrier: feed a ``<result>...`` observation to a request
221
+ paused in IDLE (empty list = empty observation)."""
222
+ state = _find_state(req_id)
223
+ if state is None:
224
+ raise KeyError(f"no tracked HybridAgent state for request {req_id!r}")
225
+ _sm_queue_observation(state, token_ids)
226
+
227
+
228
+ def record_tool_call(req_id: str, tag: str) -> None:
229
+ """T5: report an executed tool call (per-tag counter bookkeeping)."""
230
+ state = _find_state(req_id)
231
+ if state is None:
232
+ raise KeyError(f"no tracked HybridAgent state for request {req_id!r}")
233
+ _sm_record_tool_call(state, tag)
234
+
235
+
236
+ def get_state(req_id: str) -> RequestState | None:
237
+ """T5: read a request's state (mode, pending_tool, end_reason,
238
+ tool counters)."""
239
+ return _find_state(req_id)
240
+
241
+
242
+ class HybridAgentLogitsProcessor(LogitsProcessor):
243
+ """Stateful logits processor running the HybridAgent state machine."""
244
+
245
+ def __init__(
246
+ self,
247
+ vllm_config: "VllmConfig",
248
+ device: torch.device,
249
+ is_pin_memory: bool,
250
+ ):
251
+ self.device = device
252
+ # batch index -> (state, live output-token list, max_tokens,
253
+ # stop ids)
254
+ self.info: dict[
255
+ int, tuple[RequestState, list[int], int | None, frozenset[int]]
256
+ ] = {}
257
+ _PROCESSORS.append(self)
258
+
259
+ def is_argmax_invariant(self) -> bool:
260
+ # Forced tokens (latent exit, FEED) change greedy argmax.
261
+ return False
262
+
263
+ @staticmethod
264
+ def _new_info(
265
+ params: SamplingParams,
266
+ prompt_ids: list[int] | None,
267
+ output_ids: list[int],
268
+ ) -> tuple[RequestState, list[int], int | None, frozenset[int]]:
269
+ max_turns = _engine.max_turns if _engine is not None else DEFAULT_MAX_TURNS
270
+ state = RequestState(max_turns=max_turns)
271
+ # T4b: reference in_think initialization (engine.py:221/239) --
272
+ # the chat template ends in the <think> token.
273
+ eng = _engine
274
+ if eng is not None and eng.tokenizer is not None and prompt_ids:
275
+ prompt_text = eng.tokenizer.decode(
276
+ list(prompt_ids), skip_special_tokens=False
277
+ )
278
+ state.in_think = prompt_text.rstrip("\n").endswith(THINK_OPEN)
279
+ return (
280
+ state,
281
+ output_ids,
282
+ params.max_tokens,
283
+ frozenset(params.all_stop_token_ids),
284
+ )
285
+
286
+ def update_state(self, batch_update: BatchUpdate | None):
287
+ process_dict_updates(self.info, batch_update, self._new_info)
288
+
289
+ def apply(self, logits: torch.Tensor) -> torch.Tensor:
290
+ eng = _engine
291
+ if not self.info or eng is None or eng.get_req_ids is None:
292
+ return logits
293
+ req_ids = eng.get_req_ids()
294
+ if req_ids is None:
295
+ return logits
296
+ tracked = [idx for idx in self.info if idx < len(req_ids)]
297
+ if not tracked:
298
+ return logits
299
+ argmax_ids = torch.argmax(logits[tracked], dim=-1).tolist()
300
+
301
+ forced: list[tuple[int, int]] = []
302
+ masked: list[tuple[int, frozenset[int]]] = []
303
+ for idx, argmax_id in zip(tracked, argmax_ids):
304
+ st, outputs, max_tokens, stop_ids = self.info[idx]
305
+ last_token = outputs[-1] if outputs else None
306
+ tail = ""
307
+ full_text_getter = None
308
+ if st.mode == "DECODE" and eng.tokenizer is not None:
309
+ # Reference order: append the just-sampled token to the
310
+ # text-since-resume window, then take the tail of the last
311
+ # ~50 *text* tokens (observations/anchors excluded).
312
+ window = list(st.text_ids_since_resume)
313
+ if last_token is not None and last_token != eng.exit_id:
314
+ window.append(last_token)
315
+ if window:
316
+ tail = eng.tokenizer.decode(
317
+ window[-TOOL_TAG_TAIL_TOKENS:],
318
+ skip_special_tokens=False,
319
+ )
320
+ full_text_getter = eng.tokenizer.decode
321
+
322
+ action = advance(
323
+ st,
324
+ last_token=last_token,
325
+ argmax_id=argmax_id,
326
+ tail_text=tail,
327
+ full_text_getter=(
328
+ (lambda: full_text_getter(outputs,
329
+ skip_special_tokens=False))
330
+ if tail
331
+ else None
332
+ ),
333
+ entry_id=eng.entry_id,
334
+ exit_id=eng.exit_id,
335
+ stop_ids=stop_ids | eng.eos_token_ids,
336
+ max_tokens=max_tokens,
337
+ output_len=len(outputs),
338
+ think_open_id=eng.think_open_id,
339
+ think_close_id=eng.think_close_id,
340
+ )
341
+
342
+ req = (
343
+ eng.get_request(req_ids[idx])
344
+ if eng.get_request is not None
345
+ else None
346
+ )
347
+ if req is not None and st.dirty:
348
+ req.pending_latent = action.pending_latent
349
+ if action.inject_token is not None and eng.inject_token is not None:
350
+ eng.inject_token(req_ids[idx], action.inject_token)
351
+ action = StepAction(pending_latent=action.pending_latent)
352
+ if action.blocked_ids:
353
+ masked.append((idx, action.blocked_ids))
354
+ if action.force_id is not None:
355
+ forced.append((idx, action.force_id))
356
+
357
+ # T4b: reference text-mode anchor masks (engine.py:443-451 +
358
+ # sampling.py:75 masked_fill): block </latent> in all text
359
+ # mode, <latent> outside <think> (plus the think-tag guards).
360
+ # Applied before the forcing loop so forced tokens (FEED,
361
+ # latent exit) still win on their own rows.
362
+ if masked:
363
+ dev = logits.device
364
+ for idx, ids in masked:
365
+ logits[idx, torch.tensor(sorted(ids), device=dev,
366
+ dtype=torch.long)] = float("-inf")
367
+ for idx, tok in forced:
368
+ logits[idx].fill_(float("-inf"))
369
+ logits[idx, tok] = 0.0
370
+ return logits
serving/processor/pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hybridagent-vllm-processor"
7
+ version = "0.1.0"
8
+ description = "vLLM stateful logits processor for HybridAgent: DECODE/LATENT/FEED/IDLE/DONE state machine + forced </latent> (zero-token latent steps)"
9
+ requires-python = ">=3.10"
10
+ # vllm and torch are intentionally not declared: they come from the
11
+ # serving environment, mirroring hybridagent-vllm-backend.
12
+ dependencies = []
13
+
14
+ [project.entry-points."vllm.logits_processors"]
15
+ hybridagent_state_machine = "processor.processor:HybridAgentLogitsProcessor"
16
+
17
+ [tool.setuptools]
18
+ # Flat layout: the `processor` package files (__init__.py, processor.py,
19
+ # state_machine.py) live in the project root alongside pyproject.toml, not in
20
+ # a nested processor/processor/ dir. Map the package to "." so the
21
+ # vllm.logits_processors entry point (processor.processor:HybridAgentLogitsProcessor)
22
+ # resolves after an editable install.
23
+ package-dir = {"processor" = "."}
24
+ packages = ["processor"]
serving/processor/state_machine.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HybridAgent generation state machine (T4) -- pure control flow.
2
+
3
+ Mirrors the control flow of togethercomputer/HybridAgent's
4
+ ``HybridGenerationEngine`` (``hybrid_agent/engine.py`` @ HEAD 1b41cebb):
5
+ the DECODE/LATENT/FEED/IDLE/DONE mode machine, latent entry/exit
6
+ anchors, the tool barrier, observation re-injection, and the turn cap.
7
+
8
+ T4b -- reference ``blocked_token_sets`` port (engine.py:140-149):
9
+ every DECODE step now carries the text-mode anchor masks
10
+ (``StepAction.blocked_ids``): in ``<think>`` block
11
+ ``{</latent>, <think>}``, out of ``<think>`` block
12
+ ``{</latent>, <latent>, </think>}`` (guards active when the think-tag
13
+ ids are known, as in the reference default). ``in_think`` is tracked
14
+ per request from the ``<think>``/``</think>`` tokens in the generated
15
+ sequence (reference engine.py:493-496), initialized from the prompt
16
+ (engine.py:221/239) and reset when an observation is fully fed
17
+ (engine.py:434/590). LATENT exit readouts stay raw (engine.py:373),
18
+ so ``</latent>`` can only be produced by the forced latent-exit
19
+ token and ``<latent>`` only inside ``<think>``.
20
+
21
+ No vLLM/torch dependency: the vLLM-side glue (logits masking,
22
+ ``request.pending_latent`` writes, entry-point wiring) lives in
23
+ ``processor.processor``. This module is what the unit test
24
+ (``poc/test_state_machine.py``) drives directly.
25
+
26
+ Per-step contract -- the caller (the logits processor) invokes
27
+ ``advance()`` once per generation step, after the forward pass and
28
+ before sampling, with:
29
+
30
+ * ``last_token`` -- the token sampled at the previous step (``None``
31
+ before the first sample; unchanged across zero-token latent steps,
32
+ since the fork drops latent-row samples);
33
+ * ``argmax_id`` -- the argmax of the current step's logits row;
34
+ * ``tail_text`` -- decoded text of the last ~50 text tokens (the
35
+ reference tool-tag window);
36
+ * ``full_text_getter`` -- lazily decoded full output text, used only
37
+ when a tool tag fires (content extraction, reference semantics).
38
+
39
+ Latent-phase timing (reference parity): the exit decision that the
40
+ reference makes at the *start* of a step (previous step's logits,
41
+ ``latent_steps >= 4``) is made here at the *end* of the 4th latent
42
+ forward -- the point where the same counter and the same logits are
43
+ first both available in vLLM. The exit anchor is then injected into
44
+ the sequence so the next step feeds ``</latent>`` as a real token:
45
+ ``<latent>``, 4 latent forwards, ``</latent>``.
46
+
47
+ The returned :class:`StepAction` tells the caller what to do with the
48
+ current logits row and with the request's fork fields.
49
+ """
50
+ from __future__ import annotations
51
+
52
+ from dataclasses import dataclass
53
+ from typing import Callable
54
+
55
+ DECODE, LATENT, FEED, IDLE, DONE = "DECODE", "LATENT", "FEED", "IDLE", "DONE"
56
+
57
+ # HybridAgent trajectory format (reference hybrid_agent/constants.py).
58
+ ENTRY_ANCHOR = "<latent>"
59
+ EXIT_ANCHOR = "</latent>"
60
+ THINK_OPEN = "<think>"
61
+ THINK_CLOSE = "</think>"
62
+ RESULT_OPEN = "<result>"
63
+ TOOL_TAGS = ("search", "python")
64
+ TOOL_STOP_TAGS = tuple(f"</{tag}>" for tag in TOOL_TAGS)
65
+
66
+ MIN_LATENT_STEPS = 4
67
+ MAX_LATENT_STEPS = 200
68
+ TOOL_TAG_TAIL_TOKENS = 50
69
+ DEFAULT_MAX_TURNS = 30
70
+
71
+
72
+ @dataclass
73
+ class StepAction:
74
+ """What the caller must do with the current step.
75
+
76
+ Attributes:
77
+ force_id: mask the current logits row to this single token
78
+ (FEED observation tokens; latent-exit fallback when no
79
+ sequence injector is available).
80
+ pending_latent: value to write to ``request.pending_latent``
81
+ for the next scheduler pass (fork zero-token step gate).
82
+ inject_token: token id to append to the sequence *now* (the
83
+ exit anchor at a latent exit; in-process engines only).
84
+ blocked_ids: T4b -- token ids to mask to ``-inf`` in the
85
+ current logits row: the reference ``blocked_token_sets``
86
+ text-mode masks (``engine.py:140-149``). Set on DECODE
87
+ steps only; ``None`` elsewhere (LATENT exit readouts stay
88
+ raw, FEED/IDLE/DONE rows are forced/padded).
89
+ """
90
+
91
+ force_id: int | None = None
92
+ pending_latent: bool = False
93
+ inject_token: int | None = None
94
+ blocked_ids: frozenset[int] | None = None
95
+
96
+
97
+ class RequestState:
98
+ """Per-request state of one HybridAgent trajectory row."""
99
+
100
+ def __init__(
101
+ self,
102
+ max_turns: int = DEFAULT_MAX_TURNS,
103
+ min_latent_steps: int = MIN_LATENT_STEPS,
104
+ max_latent_steps: int = MAX_LATENT_STEPS,
105
+ ):
106
+ self.mode: str = DECODE
107
+ # T4b: inside the <think>...</think> region? (reference
108
+ # ``RowState.in_think``; initialized from the prompt by the
109
+ # caller, then tracked from the <think>/</think> tokens in the
110
+ # generated sequence -- engine.py:221/239/493-496).
111
+ self.in_think: bool = False
112
+ self.max_turns = int(max_turns)
113
+ self.min_latent_steps = int(min_latent_steps)
114
+ self.max_latent_steps = int(max_latent_steps)
115
+ # steps of the open latent span (completed latent forwards)
116
+ self.latent_steps: int = 0
117
+ self.turn_count: int = 1
118
+ self.feed_queue: list[int] = []
119
+ self.pending_tool: tuple[str, str] | None = None
120
+ self.python_calls: int = 0
121
+ self.search_calls: int = 0
122
+ self.tool_requests: int = 0
123
+ self.anchor_exits: int = 0
124
+ self.forced_exits: int = 0
125
+ self.end_reason: str = ""
126
+ # has left plain DECODE; gates the pending_latent writes so
127
+ # ordinary requests are a strict no-op
128
+ self.dirty: bool = False
129
+ # the <latent> token id captured at entry; a real zero-token
130
+ # step is one whose last sampled token is unchanged
131
+ self._entry_token: int | None = None
132
+ # text tokens since the last resume (tool-tag window source)
133
+ self.text_ids_since_resume: list[int] = []
134
+
135
+ @property
136
+ def is_done(self) -> bool:
137
+ return self.mode == DONE
138
+
139
+
140
+ def find_tool_tag(tail_text: str) -> str | None:
141
+ """First closing tool tag in the decoded tail (reference order)."""
142
+ for stop in TOOL_STOP_TAGS:
143
+ if stop in tail_text:
144
+ return stop
145
+ return None
146
+
147
+
148
+ def extract_content(text: str, tag: str) -> str:
149
+ """Extract text between the *last* ``<tag>...</tag>`` pair
150
+ (reference ``ToolExecutor.extract_content``)."""
151
+ start_tag = f"<{tag}>"
152
+ end_tag = f"</{tag}>"
153
+ try:
154
+ end_pos = text.rindex(end_tag)
155
+ start_pos = text.rindex(start_tag, 0, end_pos)
156
+ return text[start_pos + len(start_tag) : end_pos].strip()
157
+ except ValueError:
158
+ return ""
159
+
160
+
161
+ def _finish(state: RequestState, reason: str) -> None:
162
+ state.mode = DONE
163
+ state.end_reason = state.end_reason or reason
164
+ state.dirty = True
165
+
166
+
167
+ def _exit_latent(state: RequestState, action: StepAction, exit_id: int,
168
+ forced: bool) -> None:
169
+ state.mode = DECODE
170
+ state.latent_steps = 0
171
+ state.dirty = True
172
+ if forced:
173
+ state.forced_exits += 1
174
+ else:
175
+ state.anchor_exits += 1
176
+ action.pending_latent = False
177
+ action.inject_token = exit_id
178
+ # Fallback for engines without an injector (multi-process): mask the
179
+ # current row to the exit anchor. The fork's latent-row sample filter
180
+ # drops that sample, so this path is best-effort; the in-process
181
+ # inject_token path is the parity-correct one.
182
+ action.force_id = exit_id
183
+
184
+
185
+ def _advance_latent(
186
+ state: RequestState,
187
+ action: StepAction,
188
+ last_token: int | None,
189
+ argmax_id: int,
190
+ exit_id: int,
191
+ ) -> None:
192
+ if last_token != state._entry_token:
193
+ # Not a zero-token step: the fork scheduled a normal token step
194
+ # (the T2 entry bridge is absent, so the entry step's sample was
195
+ # appended). Do not count it as a latent forward and do not exit;
196
+ # keep the loop pending and decode undisturbed.
197
+ action.pending_latent = True
198
+ return
199
+ # A latent forward just completed (the fork drops its sample).
200
+ state.latent_steps += 1
201
+ # Reference exit order: forced cap first, then the anchor readout.
202
+ if (
203
+ state.max_latent_steps > 0
204
+ and state.latent_steps >= state.max_latent_steps
205
+ ):
206
+ _exit_latent(state, action, exit_id, forced=True)
207
+ return
208
+ if (
209
+ state.latent_steps >= state.min_latent_steps
210
+ and argmax_id == exit_id
211
+ ):
212
+ _exit_latent(state, action, exit_id, forced=False)
213
+ return
214
+ action.pending_latent = True
215
+
216
+
217
+ def _blocked_token_set(
218
+ state: RequestState,
219
+ entry_id: int,
220
+ exit_id: int,
221
+ think_open_id: int | None,
222
+ think_close_id: int | None,
223
+ ) -> frozenset[int]:
224
+ """T4b: the reference ``blocked_token_sets`` (engine.py:140-145)
225
+ for the current think state, with the think guards active when the
226
+ think-tag ids are known (the reference default ``think_guards``):
227
+
228
+ * in-think -> block ``</latent>`` (and ``<think>``)
229
+ * out-think -> block ``</latent>``, ``<latent>`` (and ``</think>``)
230
+
231
+ so ``</latent>`` can only come from the forced latent-exit readout
232
+ and ``<latent>`` only inside ``<think>``.
233
+ """
234
+ if state.in_think:
235
+ ids = {exit_id}
236
+ if think_open_id is not None:
237
+ ids.add(think_open_id)
238
+ else:
239
+ ids = {exit_id, entry_id}
240
+ if think_close_id is not None:
241
+ ids.add(think_close_id)
242
+ return frozenset(ids)
243
+
244
+
245
+ def _advance_feed(state: RequestState, action: StepAction) -> None:
246
+ # One forced observation token per step (reference FEED).
247
+ tok = state.feed_queue[0]
248
+ state.feed_queue.pop(0)
249
+ action.force_id = tok
250
+ state.dirty = True
251
+ if not state.feed_queue:
252
+ # Observation fully fed: the reference starts the next turn here.
253
+ state.text_ids_since_resume = []
254
+ state.in_think = False # T4b (reference engine.py:434)
255
+ if state.turn_count > state.max_turns:
256
+ _finish(state, "max_turns")
257
+ else:
258
+ state.mode = DECODE
259
+
260
+
261
+ def _advance_decode(
262
+ state: RequestState,
263
+ action: StepAction,
264
+ last_token: int | None,
265
+ tail_text: str,
266
+ full_text_getter: Callable[[], str] | None,
267
+ entry_id: int,
268
+ exit_id: int,
269
+ stop_ids: frozenset[int],
270
+ max_tokens: int | None,
271
+ output_len: int,
272
+ think_open_id: int | None = None,
273
+ think_close_id: int | None = None,
274
+ ) -> None:
275
+ # Budget gate (vLLM enforces max_tokens natively too; this keeps the
276
+ # mode bookkeeping consistent).
277
+ if max_tokens is not None and output_len >= max_tokens:
278
+ _finish(state, "budget")
279
+ return
280
+ if last_token is None:
281
+ # T4b: first text step after prefill -- the reference masks it
282
+ # from the start (engine.py:443-451) with the prompt-initialized
283
+ # in_think state.
284
+ action.blocked_ids = _blocked_token_set(
285
+ state, entry_id, exit_id, think_open_id, think_close_id
286
+ )
287
+ return
288
+ if last_token == entry_id:
289
+ # DECODE -> LATENT: <latent> is a real token in the sequence;
290
+ # the current step is the entry (latent) step, whose readout
291
+ # stays raw (reference engine.py:373/446).
292
+ state.mode = LATENT
293
+ state.latent_steps = 0
294
+ state._entry_token = entry_id
295
+ state.dirty = True
296
+ action.pending_latent = True
297
+ return
298
+ if last_token in stop_ids:
299
+ _finish(state, "eos")
300
+ return
301
+ # T4b: think-region tracking from the generated sequence
302
+ # (reference engine.py:493-496).
303
+ if think_open_id is not None and last_token == think_open_id:
304
+ state.in_think = True
305
+ elif think_close_id is not None and last_token == think_close_id:
306
+ state.in_think = False
307
+ # T4b: reference text-mode anchor masks on EVERY text step
308
+ # (engine.py:443-451): </latent> always, <latent> outside <think> --
309
+ # so the anchors are only produced by the state machine's forced
310
+ # tokens.
311
+ action.blocked_ids = _blocked_token_set(
312
+ state, entry_id, exit_id, think_open_id, think_close_id
313
+ )
314
+ if last_token != exit_id:
315
+ # The exit anchor is fed, not model text; keep it out of the
316
+ # tool-tag window.
317
+ state.text_ids_since_resume.append(last_token)
318
+ # Fabricated-observation guard (reference reject_fabricated_result).
319
+ if RESULT_OPEN in tail_text:
320
+ _finish(state, "invalid:fabricated_result")
321
+ return
322
+ stop = find_tool_tag(tail_text)
323
+ if stop is not None:
324
+ tag = stop[2:-1]
325
+ full_text = (
326
+ full_text_getter() if full_text_getter is not None else tail_text
327
+ )
328
+ state.pending_tool = (tag, extract_content(full_text, tag))
329
+ state.tool_requests += 1
330
+ state.mode = IDLE
331
+ state.dirty = True
332
+
333
+
334
+ def advance(
335
+ state: RequestState,
336
+ *,
337
+ last_token: int | None,
338
+ argmax_id: int,
339
+ tail_text: str = "",
340
+ full_text_getter: Callable[[], str] | None = None,
341
+ entry_id: int,
342
+ exit_id: int,
343
+ stop_ids: frozenset[int] = frozenset(),
344
+ max_tokens: int | None = None,
345
+ output_len: int = 0,
346
+ think_open_id: int | None = None,
347
+ think_close_id: int | None = None,
348
+ ) -> StepAction:
349
+ """Advance one request's state machine by one generation step."""
350
+ action = StepAction()
351
+ if state.mode == DONE:
352
+ return action
353
+ if state.mode == LATENT:
354
+ _advance_latent(state, action, last_token, argmax_id, exit_id)
355
+ return action
356
+ if state.mode == FEED:
357
+ _advance_feed(state, action)
358
+ return action
359
+ if state.mode == IDLE:
360
+ # Paused at the tool barrier; T5 resumes via queue_observation().
361
+ return action
362
+ _advance_decode(
363
+ state,
364
+ action,
365
+ last_token,
366
+ tail_text,
367
+ full_text_getter,
368
+ entry_id,
369
+ exit_id,
370
+ stop_ids,
371
+ max_tokens,
372
+ output_len,
373
+ think_open_id,
374
+ think_close_id,
375
+ )
376
+ return action
377
+
378
+
379
+ def queue_observation(state: RequestState, token_ids: list[int]) -> None:
380
+ """T5 tool barrier: resume an IDLE request with an observation
381
+ (``<result>...`` token ids). Mirrors the reference
382
+ ``_tool_barrier``: the turn is counted; a non-empty observation
383
+ enters FEED (one forced token per step); an empty one goes straight
384
+ back to DECODE (or DONE past max_turns)."""
385
+ if state.mode != IDLE:
386
+ raise ValueError(
387
+ f"queue_observation in mode {state.mode} (expected IDLE)"
388
+ )
389
+ state.pending_tool = None
390
+ state.turn_count += 1
391
+ state.dirty = True
392
+ if token_ids:
393
+ state.feed_queue = list(token_ids)
394
+ state.mode = FEED
395
+ else:
396
+ state.text_ids_since_resume = []
397
+ state.in_think = False # T4b (reference engine.py:590)
398
+ if state.turn_count > state.max_turns:
399
+ _finish(state, "max_turns")
400
+ else:
401
+ state.mode = DECODE
402
+
403
+
404
+ def record_tool_call(state: RequestState, tag: str) -> None:
405
+ """T5: report an executed tool call (per-tag counter bookkeeping;
406
+ cap enforcement is T5's)."""
407
+ if tag == "python":
408
+ state.python_calls += 1
409
+ elif tag == "search":
410
+ state.search_calls += 1
serving/processor/test_blocked_masks.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/opt/hybridagent-vllm/venv/bin/python
2
+ """T4b -- tests for the reference ``blocked_token_sets`` port.
3
+
4
+ Validates the token-blocking masks ported from
5
+ togethercomputer/HybridAgent's ``HybridGenerationEngine``
6
+ (``hybrid_agent/engine.py:140-149`` sets, ``:221/:239`` prompt
7
+ initialization, ``:443-451`` per-step application, ``:493-496``
8
+ think tracking, ``:434/:590`` FEED reset; ``sampling.py:75``
9
+ masked_fill) into the T4 state machine + vLLM logits processor:
10
+
11
+ in_think blocks {</latent>, <think>}
12
+ out_think blocks {</latent>, <latent>, </think>}
13
+
14
+ applied to every DECODE (text) logits row, from the very first step,
15
+ so ``</latent>`` can only come from the forced latent-exit readout
16
+ and ``<latent>`` only inside ``<think>``. LATENT exit readouts stay
17
+ raw; FEED/exit forcing is untouched.
18
+
19
+ Run:
20
+ /opt/hybridagent-vllm/venv/bin/python \
21
+ /opt/hybridagent-vllm/processor/test_blocked_masks.py
22
+
23
+ Exit code 0 = PASS, 1 = FAIL.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ # This file lives inside the processor/ package dir; drop that dir from
31
+ # sys.path (the script dir) so `import processor` resolves to the
32
+ # package itself, not processor/processor.py.
33
+ _HERE = Path(__file__).resolve().parent
34
+ sys.path = [p for p in sys.path if not p or Path(p).resolve() != _HERE]
35
+ sys.path.insert(0, str(_HERE.parent))
36
+
37
+ import torch
38
+
39
+ import processor
40
+ import processor.processor as pp
41
+ from processor import HybridAgentLogitsProcessor
42
+ from processor.state_machine import (
43
+ DECODE,
44
+ DONE,
45
+ FEED,
46
+ IDLE,
47
+ LATENT,
48
+ RequestState,
49
+ advance,
50
+ queue_observation,
51
+ )
52
+ from vllm.sampling_params import SamplingParams
53
+ from vllm.v1.sample.logits_processor import BatchUpdate
54
+
55
+ # Token plan (V=64): 10=<latent>, 11=</latent>, 12=<think>, 13=</think>,
56
+ # 14=eos, 20/21=observation tokens, 40/41/30=<python>/print(1)//</python>.
57
+ V = 64
58
+ L, R, T, TC, EOS = 10, 11, 12, 13, 14
59
+ PLAIN, B2 = 5, 6
60
+
61
+
62
+ class StubTok:
63
+ unk_token_id = None
64
+ eos_token_id = EOS
65
+ SPECIAL = {
66
+ "<latent>": L,
67
+ "</latent>": R,
68
+ "<think>": T,
69
+ "</think>": TC,
70
+ "<result>": 20,
71
+ "</result>": 22,
72
+ "<python>": 40,
73
+ "</python>": 30,
74
+ "<search>": 43,
75
+ "</search>": 31,
76
+ }
77
+ TEXT = {5: "hello", 6: " world", 41: "print(1)"}
78
+
79
+ def __init__(self):
80
+ self.id2str = {**self.TEXT, **{v: k for k, v in self.SPECIAL.items()}}
81
+
82
+ def convert_tokens_to_ids(self, token):
83
+ return self.SPECIAL.get(token)
84
+
85
+ def decode(self, ids, skip_special_tokens=False):
86
+ return "".join(self.id2str.get(i, f" t{i} ") for i in ids)
87
+
88
+
89
+ class FakeRequest:
90
+ def __init__(self):
91
+ self.pending_latent = False
92
+ self.engine_outputs: list[int] = []
93
+
94
+
95
+ class FakeEngine:
96
+ def __init__(self, tokenizer):
97
+ self.tokenizer = tokenizer
98
+ self.requests = {"reqA": FakeRequest()}
99
+ self.worker_lists: dict[str, list[int]] = {"reqA": []}
100
+ self.inject_log: list[tuple[str, int]] = []
101
+
102
+ def get_request(self, rid):
103
+ return self.requests.get(rid)
104
+
105
+ def get_req_ids(self):
106
+ return ["reqA"]
107
+
108
+ def append(self, rid, tok):
109
+ self.requests[rid].engine_outputs.append(tok)
110
+ self.worker_lists[rid].append(tok)
111
+
112
+ def inject_token(self, rid, tok):
113
+ self.append(rid, tok)
114
+ self.inject_log.append((rid, tok))
115
+
116
+
117
+ def make_proc(prompt_ids: list[int]):
118
+ pp._engine = None
119
+ pp._PROCESSORS.clear()
120
+ tok = StubTok()
121
+ eng = FakeEngine(tok)
122
+ pp.register_engine(
123
+ get_request=eng.get_request,
124
+ get_req_ids=eng.get_req_ids,
125
+ tokenizer=tok,
126
+ inject_token=eng.inject_token,
127
+ )
128
+ proc = HybridAgentLogitsProcessor(
129
+ vllm_config=None, device=torch.device("cpu"), is_pin_memory=False
130
+ )
131
+ params = SamplingParams(max_tokens=64, temperature=0.0)
132
+ out: list[int] = []
133
+ eng.worker_lists["reqA"] = out
134
+ proc.update_state(
135
+ BatchUpdate(
136
+ batch_size=1,
137
+ removed=(),
138
+ added=((0, params, list(prompt_ids), out),),
139
+ moved=(),
140
+ )
141
+ )
142
+ return proc, eng, tok
143
+
144
+
145
+ def sm(state: RequestState, **kw):
146
+ defaults = dict(
147
+ last_token=None,
148
+ argmax_id=PLAIN,
149
+ tail_text="",
150
+ full_text_getter=None,
151
+ entry_id=L,
152
+ exit_id=R,
153
+ stop_ids=frozenset({EOS}),
154
+ max_tokens=None,
155
+ output_len=0,
156
+ think_open_id=T,
157
+ think_close_id=TC,
158
+ )
159
+ defaults.update(kw)
160
+ return advance(state, **defaults)
161
+
162
+
163
+ failures: list[str] = []
164
+
165
+
166
+ def check(name, cond, detail=""):
167
+ status = "ok" if cond else "FAIL"
168
+ print(f" [{name}] {status}" + (f" -- {detail}" if detail and not cond else ""))
169
+ if not cond:
170
+ failures.append(f"{name}: {detail}")
171
+
172
+
173
+ def main() -> int:
174
+ # ------------------------------------------------------------------
175
+ print("== SM1: in-think blocked set (engine.py:140-144) ==")
176
+ st = RequestState()
177
+ st.in_think = True
178
+ a = sm(st)
179
+ check("in-think blocks {</latent>, <think>}",
180
+ a.blocked_ids == frozenset({R, T}), str(a.blocked_ids))
181
+
182
+ print("== SM2: out-of-think blocked set (engine.py:140-144) ==")
183
+ st = RequestState()
184
+ st.in_think = False
185
+ a = sm(st)
186
+ check("out-of-think blocks {</latent>, <latent>, </think>}",
187
+ a.blocked_ids == frozenset({R, L, TC}), str(a.blocked_ids))
188
+
189
+ print("== SM3: mask active from the very first text step ==")
190
+ st = RequestState()
191
+ a = sm(st) # last_token=None
192
+ check("first step carries a blocked set",
193
+ a.blocked_ids is not None, str(a.blocked_ids))
194
+
195
+ print("== SM4: in_think tracked from <think>/</think> tokens ==")
196
+ st = RequestState()
197
+ sm(st, last_token=T)
198
+ check("sampling <think> sets in_think", st.in_think is True, str(st.in_think))
199
+ sm(st, last_token=TC)
200
+ check("sampling </think> clears in_think", st.in_think is False,
201
+ str(st.in_think))
202
+
203
+ print("== SM5: FEED drain resets in_think (engine.py:434) ==")
204
+ st = RequestState()
205
+ st.mode = FEED
206
+ st.feed_queue = [20, 21]
207
+ st.in_think = True
208
+ a = sm(st)
209
+ check("feed step 1 forces token 20", a.force_id == 20, str(a.force_id))
210
+ check("feed step 1 has no text mask", a.blocked_ids is None,
211
+ str(a.blocked_ids))
212
+ a = sm(st)
213
+ check("feed drain forces last token", a.force_id == 21, str(a.force_id))
214
+ check("drain returns to DECODE", st.mode == DECODE, st.mode)
215
+ check("drain resets in_think", st.in_think is False, str(st.in_think))
216
+
217
+ print("== SM6: empty observation resets in_think (engine.py:590) ==")
218
+ st = RequestState()
219
+ st.mode = IDLE
220
+ st.in_think = True
221
+ queue_observation(st, [])
222
+ check("empty observation -> DECODE", st.mode == DECODE, st.mode)
223
+ check("in_think reset", st.in_think is False, str(st.in_think))
224
+
225
+ print("== SM7: LATENT exit readout stays raw; forcing intact ==")
226
+ st = RequestState()
227
+ st.mode = LATENT
228
+ st._entry_token = L
229
+ a = sm(st, last_token=L, argmax_id=PLAIN)
230
+ check("latent step has no text mask", a.blocked_ids is None,
231
+ str(a.blocked_ids))
232
+ check("latent step keeps pending_latent", a.pending_latent is True,
233
+ str(a.pending_latent))
234
+ for _ in range(3):
235
+ st.latent_steps += 1
236
+ a = sm(st, last_token=L, argmax_id=R)
237
+ check("anchor exit still forced at min steps",
238
+ a.force_id == R and a.inject_token == R, str(a))
239
+ check("exit clears pending_latent", a.pending_latent is False,
240
+ str(a.pending_latent))
241
+ check("exit back in DECODE", st.mode == DECODE, st.mode)
242
+
243
+ print("== SM8: IDLE/DONE carry no mask ==")
244
+ st = RequestState()
245
+ st.mode = IDLE
246
+ check("IDLE no mask", sm(st).blocked_ids is None)
247
+ st = RequestState()
248
+ st.mode = DONE
249
+ check("DONE no mask", sm(st).blocked_ids is None)
250
+
251
+ print("== SM9: unknown think tokens -> anchor-only sets ==")
252
+ st = RequestState()
253
+ st.in_think = True
254
+ a = sm(st, think_open_id=None, think_close_id=None)
255
+ check("in-think without guards = {</latent>}",
256
+ a.blocked_ids == frozenset({R}), str(a.blocked_ids))
257
+ st.in_think = False
258
+ a = sm(st, think_open_id=None, think_close_id=None)
259
+ check("out-think without guards = {</latent>, <latent>}",
260
+ a.blocked_ids == frozenset({R, L}), str(a.blocked_ids))
261
+
262
+ # ------------------------------------------------------------------
263
+ print("== P1: DECODE row masked at the blocked ids only ==")
264
+ proc, eng, tok = make_proc([T]) # prompt ends in <think>
265
+ st = processor.get_state("reqA")
266
+ check("in_think initialized from prompt", st.in_think is True,
267
+ str(st.in_think))
268
+ logits = torch.full((1, V), -10.0, dtype=torch.float32)
269
+ logits[0, R] = 0.0 # greedy argmax is the exit anchor
270
+ logits[0, B2] = -5.0
271
+ proc.apply(logits)
272
+ check("</latent> masked to -inf",
273
+ torch.isinf(logits[0, R]) and logits[0, R] < 0, str(logits[0, R]))
274
+ check("<think> masked to -inf (in-think guard)",
275
+ torch.isinf(logits[0, T]) and logits[0, T] < 0, str(logits[0, T]))
276
+ check("<latent> NOT blocked in-think",
277
+ logits[0, L] == -10.0, str(logits[0, L]))
278
+ check("other logits untouched",
279
+ logits[0, PLAIN] == -10.0 and logits[0, B2] == -5.0,
280
+ str(logits[0, [PLAIN, B2]]))
281
+ check("greedy argmax flips away from </latent>",
282
+ int(logits[0].argmax()) == B2, str(int(logits[0].argmax())))
283
+
284
+ print("== P2: out-of-think row blocks <latent> ==")
285
+ proc, eng, tok = make_proc([5]) # prompt does not end in <think>
286
+ st = processor.get_state("reqA")
287
+ check("in_think False for plain prompt", st.in_think is False,
288
+ str(st.in_think))
289
+ logits = torch.full((1, V), -10.0, dtype=torch.float32)
290
+ logits[0, L] = 0.0 # greedy argmax is the entry anchor
291
+ logits[0, B2] = -5.0
292
+ proc.apply(logits)
293
+ check("<latent> masked to -inf",
294
+ torch.isinf(logits[0, L]) and logits[0, L] < 0, str(logits[0, L]))
295
+ check("</think> masked to -inf (out-of-think guard)",
296
+ torch.isinf(logits[0, TC]) and logits[0, TC] < 0,
297
+ str(logits[0, TC]))
298
+ check("<think> NOT blocked out-of-think",
299
+ logits[0, T] == -10.0, str(logits[0, T]))
300
+ check("greedy argmax flips away from <latent>",
301
+ int(logits[0].argmax()) == B2, str(int(logits[0].argmax())))
302
+
303
+ print("== P3: FEED forcing is not weakened by the masks ==")
304
+ proc, eng, tok = make_proc([T])
305
+ # The tool tag fires on the apply() step AFTER </python> is sampled.
306
+ script = [40, 41, 30, PLAIN]
307
+ for arg in script:
308
+ logits = torch.full((1, V), -10.0, dtype=torch.float32)
309
+ logits[0, arg] = 0.0
310
+ proc.apply(logits)
311
+ eng.append("reqA", int(logits[0].argmax()))
312
+ st = processor.get_state("reqA")
313
+ check("tool tag -> IDLE with pending_tool",
314
+ st.mode == IDLE and st.pending_tool == ("python", "print(1)"),
315
+ f"{st.mode} {st.pending_tool}")
316
+ processor.queue_observation("reqA", [20, 21])
317
+ st = processor.get_state("reqA")
318
+ check("IDLE -> FEED", st.mode == FEED, st.mode)
319
+ logits = torch.full((1, V), -10.0, dtype=torch.float32)
320
+ proc.apply(logits)
321
+ n_inf = int(torch.isneginf(logits[0]).sum())
322
+ check("feed row forced to token 20",
323
+ logits[0, 20] == 0.0 and n_inf == V - 1,
324
+ f"tok20={logits[0, 20]} n_inf={n_inf}")
325
+
326
+ print("== P4: LATENT row logits stay raw (exit readout unmasked) ==")
327
+ proc, eng, tok = make_proc([T])
328
+ logits = torch.full((1, V), -10.0, dtype=torch.float32)
329
+ logits[0, L] = 0.0
330
+ proc.apply(logits)
331
+ eng.append("reqA", int(logits[0].argmax())) # <latent> sampled
332
+ # Next apply: the state machine sees last_token=<latent> -> LATENT.
333
+ before = torch.full((1, V), -10.0, dtype=torch.float32)
334
+ before[0, R] = 0.0
335
+ logits = before.clone()
336
+ proc.apply(logits)
337
+ st = processor.get_state("reqA")
338
+ check("entered LATENT", st.mode == LATENT, st.mode)
339
+ check("latent row byte-identical (no mask, no force)",
340
+ torch.equal(logits, before), str(logits[0]))
341
+
342
+ # ------------------------------------------------------------------
343
+ print()
344
+ if failures:
345
+ print(f"FAIL ({len(failures)} problem(s)):")
346
+ for f in failures:
347
+ print(f" - {f}")
348
+ return 1
349
+ print("PASS: T4b blocked_token_sets masks (state machine + processor)")
350
+ return 0
351
+
352
+
353
+ if __name__ == "__main__":
354
+ sys.exit(main())
serving/serve.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """T7b — serve the HybridAgent-8B backbone with OUR stack.
3
+
4
+ This is NOT the `vllm serve` CLI. The HybridAgent latent loop needs
5
+ IN-PROCESS engine access (the T4 state machine reaches engine-side
6
+ `Request`s through registered handles, and the T5 orchestrator appends
7
+ tokens via direct request mutation), so the engine runs in this process
8
+ (`VLLM_ENABLE_V1_MULTIPROCESSING=0`) and is driven by the
9
+ `HybridAgentOrchestrator`.
10
+
11
+ T7b-full (this configuration) serves the **EXL3-quantized** backbone
12
+ (T6: 5.0 bpw/H5, `model.safetensors` 5.98 GB) from the staged serving
13
+ dir `serve/exl3-staged/` — symlinks to `models/HybridAgent-8B-EXL3/`
14
+ plus the tokenizer files back-filled from the BF16 dir (stage-5 gap:
15
+ `convert.py` did not retain them). The HybridLink `adapter.pt` loads
16
+ as BF16 next to the checkpoint (S4). The KV budget is 15e9 B
17
+ (~101k tokens ≈ 3 concurrent full-32k sessions). The BF16 smoke
18
+ config (T7b-smoke) is still reachable via env:
19
+ `HYBRIDAGENT_MODEL_DIR=/opt/hybridagent-vllm/models/HybridAgent-8B
20
+ HYBRIDAGENT_KV_BYTES=5000000000`.
21
+
22
+ Required engine semantics (feature doc L6/L8, T2 fork):
23
+ * VLLM_USE_V2_MODEL_RUNNER=0 — the fork modifies the V1 runner
24
+ (`v1/worker/gpu_model_runner.py`); vLLM 0.30 defaults to V2, where the
25
+ latent path would be silently inactive.
26
+ * async_scheduling=False — forced by the orchestrator; the async
27
+ scheduler keeps `num_new_tokens > 0` every step, which blocks the
28
+ fork's zero-token latent gate.
29
+ * enable_prompt_embeds=True — the fork feeds R(h) latent embeddings
30
+ through the mixed-batch prompt-embeds path (is_token_ids=False);
31
+ the T7a parity run booted with it.
32
+ * enforce_eager=True — the proven parity config; avoids CUDA-graph
33
+ memory overhead on the 24 GB 3090.
34
+
35
+ KV sizing (BF16): the guide fixes the KV of the hybrid (GDN) models with
36
+ `--kv-cache-memory-bytes` because GPU-memory profiling underestimates GDN
37
+ layers and OOMs. HybridAgent-8B is a PLAIN full-attention transformer
38
+ (36 × full_attention, no GDN), so utilization-based sizing would be
39
+ acceptable; we keep the explicit `kv_cache_memory_bytes` because it makes
40
+ the VRAM budget deterministic against the "≥100 MiB really free" battery
41
+ criterion. BF16 weights are ~15.7 GB (vs ~5.4 GB EXL3), so the BF16 KV
42
+ budget is 5e9 B (~33.9k tokens ≈ one full 32k session with margin);
43
+ see README.md for the numbers.
44
+
45
+ Usage (on tarninfer):
46
+ cd /opt/hybridagent-vllm
47
+ venv/bin/python serve/serve.py
48
+ then type one prompt per line; a BLANK line flushes (runs all pending
49
+ prompts in one batched loop) and prints their final texts; EOF exits.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import os
54
+ import sys
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Environment — MUST be set before vllm is imported.
58
+ # (The orchestrator module also setdefaults the two fork-semantics vars;
59
+ # setting them here keeps this file correct if imported standalone.)
60
+ # ---------------------------------------------------------------------------
61
+ os.environ.setdefault("VLLM_USE_V2_MODEL_RUNNER", "0") # fork is V1 (L6)
62
+ os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0") # in-process core
63
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
64
+ os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
65
+ os.environ.setdefault("CUDA_HOME", "/usr/local/cuda-13.2")
66
+ os.environ.setdefault("VLLM_CACHE_ROOT", "/var/tmp/vllm-hybridagent")
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Configuration (T7b-full: EXL3 checkpoint; see README.md)
70
+ # ---------------------------------------------------------------------------
71
+ # The serving dir: EXL3 weights + adapter.pt + adapter_config.json +
72
+ # tokenizer files, in ONE directory (the T3 backend loads the adapter from
73
+ # next to the checkpoint). serve/exl3-staged/ is the T6 EXL3 dir
74
+ # (models/HybridAgent-8B-EXL3) with symlinks plus the tokenizer files
75
+ # back-filled from the BF16 dir (the stage-5 "verify tokenizer files"
76
+ # step). Point HYBRIDAGENT_MODEL_DIR at the BF16 dir to re-run the
77
+ # T7b-smoke config.
78
+ MODEL_DIR = os.environ.get(
79
+ "HYBRIDAGENT_MODEL_DIR",
80
+ "/opt/hybridagent-vllm/serve/exl3-staged",
81
+ )
82
+ MAX_MODEL_LEN = 32_768 # the model's native context window
83
+ # EXL3 weights are ~5.98 GB (+0.07 GB BF16 adapter), so the KV budget is
84
+ # 15e9 B / 147,456 B-per-token ≈ 101.7k tokens ≈ 3 concurrent full-32k
85
+ # sessions. The BF16 smoke used 5e9 (≈33.9k tokens) against its 15.7 GB
86
+ # weights.
87
+ KV_CACHE_MEMORY_BYTES = int(
88
+ os.environ.get("HYBRIDAGENT_KV_BYTES", "15_000_000_000")
89
+ .replace("_", "")
90
+ )
91
+ # Total vLLM memory budget (fraction of the 24 GB). EXL3 weights
92
+ # (~5.98 GB) + KV (15e9 B ≈ 14.2 GB) + runtime (~2 GB) ≈ 22.2 GB →
93
+ # 0.93 (≈22.9 GB) leaves ≥ ~100 MiB really free for the battery
94
+ # criterion.
95
+ GPU_MEMORY_UTILIZATION = float(os.environ.get("HYBRIDAGENT_GPU_UTIL", "0.93"))
96
+ MAX_NUM_BATCHED_TOKENS = 1_024 # the guide's serving services use 1024
97
+ DTYPE = "bfloat16"
98
+ ENFORCE_EAGER = True # proven parity config (T7a)
99
+ ENABLE_PROMPT_EMBEDS = True # fork latent-embed mixed-batch path
100
+ MAX_TURNS = 30 # the reference engine's turn cap
101
+ MAX_TOKENS_PER_REQ = 4_096 # per-prompt output budget
102
+
103
+
104
+ def main() -> int:
105
+ # The two engine-side plugins must be installed in this venv
106
+ # (pip install -e backend, pip install -e processor); an early import
107
+ # fails fast with a clear message instead of mid-boot.
108
+ import backend # noqa: F401 (vllm.general_plugins entry point)
109
+ import processor # noqa: F401 (vllm.logits_processors entry point)
110
+ # The orchestrator package is NOT installed (no entry point); the
111
+ # repo root must be on the path. Add it AFTER backend/processor are
112
+ # imported: with REPO first, `import backend` would resolve to the
113
+ # namespace dir /opt/hybridagent-vllm/backend/ and shadow the
114
+ # installed package (same ordering rule as battery/run_battery.py).
115
+ sys.path.insert(
116
+ 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
117
+ from orchestrator import HybridAgentOrchestrator
118
+
119
+ print(
120
+ f"[serve] T7b-full — model={MODEL_DIR}\n"
121
+ f"[serve] max_model_len={MAX_MODEL_LEN} "
122
+ f"kv_cache_memory_bytes={KV_CACHE_MEMORY_BYTES} "
123
+ f"gpu_memory_utilization={GPU_MEMORY_UTILIZATION} "
124
+ f"max_num_batched_tokens={MAX_NUM_BATCHED_TOKENS} dtype={DTYPE}\n"
125
+ f"[serve] VLLM_USE_V2_MODEL_RUNNER=0, async_scheduling=False (forced), "
126
+ f"enforce_eager={ENFORCE_EAGER}, enable_prompt_embeds={ENABLE_PROMPT_EMBEDS}"
127
+ )
128
+ if not os.path.isfile(os.path.join(MODEL_DIR, "adapter.pt")):
129
+ print(
130
+ f"[serve] WARNING: adapter.pt not found in {MODEL_DIR} — "
131
+ "the T3 backend will refuse to load. Fix HYBRIDAGENT_MODEL_DIR."
132
+ )
133
+
134
+ orch = HybridAgentOrchestrator(
135
+ model=MODEL_DIR,
136
+ max_turns=MAX_TURNS,
137
+ dtype=DTYPE,
138
+ enforce_eager=ENFORCE_EAGER,
139
+ enable_prompt_embeds=ENABLE_PROMPT_EMBEDS,
140
+ max_model_len=MAX_MODEL_LEN,
141
+ kv_cache_memory_bytes=KV_CACHE_MEMORY_BYTES,
142
+ gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
143
+ max_num_batched_tokens=MAX_NUM_BATCHED_TOKENS,
144
+ )
145
+ print("[serve] engine up; one prompt per line, blank line = run batch")
146
+
147
+ def flush(prompts: list[str]) -> None:
148
+ if not prompts:
149
+ return
150
+ rids = [
151
+ orch.add_request(p, max_tokens=MAX_TOKENS_PER_REQ) for p in prompts
152
+ ]
153
+ orch.run() # drives every submitted request to DONE (batched loop)
154
+ for rid in rids:
155
+ text = orch.final_text(rid)
156
+ print(f"\n[serve] === {rid} ===\n{text}\n", flush=True)
157
+
158
+ pending: list[str] = []
159
+ try:
160
+ for line in sys.stdin:
161
+ line = line.rstrip("\n")
162
+ if not line:
163
+ flush(pending)
164
+ pending = []
165
+ else:
166
+ pending.append(line)
167
+ flush(pending)
168
+ except KeyboardInterrupt:
169
+ print("\n[serve] interrupted; shutting down")
170
+ finally:
171
+ orch.shutdown()
172
+ return 0
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
serving/setup.sh ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Deploy the HybridAgent-8B EXL3 model with the forked vLLM stack.
3
+ # Run from the serving/ directory of a fresh clone of this repo:
4
+ # git clone https://huggingface.co/JRamirez-UAB/hybridagent-8b-exl3-vllm-experimental
5
+ # cd hybridagent-8b-exl3-vllm-experimental/serving
6
+ # ./setup.sh
7
+ #
8
+ # Stack: Python 3.12, vLLM 0.30.0, torch 2.13.0, exllamav3 1.5.1,
9
+ # OrcaSAQ2-kernel (EXL3 plugin), the hybridagent backend+processor,
10
+ # and the fork's v1 runner files overlaid onto the installed vLLM.
11
+ set -euo pipefail
12
+ cd "$(dirname "$0")" # serving/
13
+
14
+ PY=${PY:-3.12}
15
+ VENV=${VENV:-../venv}
16
+ ORCASAQ2_COMMIT=${ORCASAQ2_COMMIT:-7ddffb12c00c24b8bc6b1164723c04ac490a91bc}
17
+
18
+ echo "[1/6] venv (python ${PY})"
19
+ python${PY} -m venv "${VENV}"
20
+ PIP="${VENV}/bin/pip"
21
+ "${PIP}" install -U pip
22
+
23
+ echo "[2/6] vLLM 0.30.0 (pulls torch 2.13.0)"
24
+ "${PIP}" install "vllm==0.30.0"
25
+
26
+ echo "[3/6] exllamav3 1.5.1 (cu132, torch 2.13.0, cp312)"
27
+ W="exllamav3-1.5.1+cu132.torch2.13.0-cp312-cp312-linux_x86_64.whl"
28
+ curl -L -o "/tmp/${W}" "https://github.com/turboderp-org/exllamav3/releases/download/v1.5.1/${W/+/%2B}"
29
+ "${PIP}" install "/tmp/${W}"
30
+
31
+ echo "[4/6] OrcaSAQ2-kernel (EXL3 plugin for vLLM)"
32
+ rm -rf /tmp/OrcaSAQ2-kernel
33
+ git clone https://github.com/Continuum-AI-Corp/OrcaSAQ2-kernel /tmp/OrcaSAQ2-kernel
34
+ git -C /tmp/OrcaSAQ2-kernel checkout "${ORCASAQ2_COMMIT}"
35
+ "${PIP}" install -e /tmp/OrcaSAQ2-kernel
36
+
37
+ echo "[5/6] hybridagent engine-side plugins (backend + processor)"
38
+ "${PIP}" install -e ./backend -e ./processor
39
+
40
+ echo "[6/6] overlay the fork onto the installed vLLM v1 runner"
41
+ SP="$("${VENV}/bin/python" -c 'import vllm, os; print(os.path.dirname(vllm.__file__))')"
42
+ cp -v fork/v1/request.py "${SP}/v1/request.py"
43
+ cp -v fork/v1/worker/gpu_model_runner.py "${SP}/v1/worker/gpu_model_runner.py"
44
+ cp -v fork/v1/core/kv_cache_manager.py "${SP}/v1/core/kv_cache_manager.py"
45
+ cp -v fork/v1/core/sched/output.py "${SP}/v1/core/sched/output.py"
46
+ cp -v fork/v1/core/sched/scheduler.py "${SP}/v1/core/sched/scheduler.py"
47
+
48
+ echo
49
+ echo "Setup complete. Serve with (MODEL_DIR = the repo root, which holds the weights):"
50
+ echo " cd .."
51
+ echo " HYBRIDAGENT_MODEL_DIR=\$(pwd) ${VENV}/bin/python serving/serve.py"